feat: actualización a v1.1.9 con Agenda interactiva y correcciones visuales
This commit is contained in:
Vendored
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"Webnative.webnative"
|
"Webnative.webnative"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,67 +2,126 @@
|
|||||||
|
|
||||||
Un visor híbrido especializado y offline-first desarrollado para la visualización de la cartelera oficial de aulas, seguimiento automatizado de materias cursadas y generación de la Credencial Digital Universitaria para estudiantes de la Universidad de la Ciudad (UNICABA).
|
Un visor híbrido especializado y offline-first desarrollado para la visualización de la cartelera oficial de aulas, seguimiento automatizado de materias cursadas y generación de la Credencial Digital Universitaria para estudiantes de la Universidad de la Ciudad (UNICABA).
|
||||||
|
|
||||||
## Información Técnica y Frameworks
|
---
|
||||||
|
|
||||||
* **Base Frontend**: [Angular 20.3.x](https://angular.io/)
|
## ✨ Características
|
||||||
* **Librería de Componentes UI**: [Ionic Framework v8](https://ionicframework.com/)
|
|
||||||
* **Puente Nativo Multiplataforma**: [Capacitor v8](https://capacitorjs.com/)
|
|
||||||
* **Procesamiento de Archivos PDF Interno**: `pdfjs-dist` (Renderización nativa in-dom de la Credencial)
|
|
||||||
|
|
||||||
## Plugins de Experiencia Nativa (Capacitor)
|
### 📋 Cartelera de Horarios
|
||||||
|
- Visualización completa de la cartelera oficial de aulas organizada por día de la semana (Lunes a Sábado).
|
||||||
|
- **Modo offline-first**: la cartelera se cachea localmente al primer acceso y se muestra instantáneamente aunque no haya conexión.
|
||||||
|
- **Sincronización automática** en background (Background Fetch) delegada al sistema operativo.
|
||||||
|
- **Búsqueda fuzzy** tolerante a errores tipográficos: busca por nombre de materia, código, aula o carrera.
|
||||||
|
- **Filtro por vista**: alternás entre "Favoritos" y "Cartelera completa" con un segmento.
|
||||||
|
- Acceso directo a clases virtuales (Zoom / Meet) desde el botón de cada materia.
|
||||||
|
- Función de compartir el horario de una materia por WhatsApp, Telegram, y cualquier app nativa.
|
||||||
|
|
||||||
La aplicación invoca funciones 100% nativas apoyándose en las siguientes librerías core:
|
### ⭐ Favoritos Inteligentes
|
||||||
- `@capacitor/filesystem`: Escritura pesada persistente de respaldos en CSV en el FileSystem local ("Documents").
|
- Marcá hasta 5 materias como favoritas para tenerlas a mano en su propia pestaña.
|
||||||
- `@capawesome/capacitor-file-picker`: Interfaz de File Picker del OS (Cloud / Storage) para restauración manual.
|
- Los favoritos se guardan automáticamente en un archivo `aulas_unicaba_datos.csv` en la carpeta "Documentos" del dispositivo, **sobreviviendo a desinstalaciones y actualizaciones** gracias a las políticas de almacenamiento de Android 11+.
|
||||||
- `@capacitor/local-notifications`: Motor de avisos visuales locales ante un cambio repentino de aula mapeada en favoritos.
|
- **Alertas locales automáticas**: si una materia favorita cambia de aula o link de clase virtual, la app lo detecta en el próximo sincronismo y dispara una notificación push inmediata.
|
||||||
- `@capacitor/preferences`: Almacenamiento rápido volátil (Key-Value) para estado de caché de credenciales offline y preferencias menores.
|
- Restauración manual de favoritos desde un respaldo CSV (Google Drive, iCloud, Descargas).
|
||||||
- `@transistorsoft/capacitor-background-fetch`: Cronjobs transparentes delegados al Sistema Operativo para despertar a la app y sincronizar la cartelera.
|
|
||||||
- `@capacitor-community/screen-brightness`: Anulación y control programático de brillo de panel al desplegar formatos estáticos de código QR/Credencial UI.
|
### 🎓 Banner "Próxima Clase" *(Nuevo en v1.1.6)*
|
||||||
- `@capacitor/haptics`: Respuestas hápticas (Vibración inteligente) en interacciones de hardware/software clave.
|
- Al entrar a la pestaña **Favoritos**, aparece automáticamente un banner destacado con la materia favorita cuya clase es la más cercana en el tiempo (hoy o mañana).
|
||||||
- `@capacitor/share`: Puente social nativo para el envío de horarios por WhatsApp, Telegram, etc.
|
- **Chip de countdown** en tiempo real que se actualiza cada minuto:
|
||||||
|
- 🔵 Azul → más de 2 horas
|
||||||
|
- 🟡 Amarillo → entre 30 minutos y 2 horas
|
||||||
|
- 🔴 Rojo + animación de pulso → menos de 30 minutos
|
||||||
|
- Muestra nombre de la materia, número de aula y día + hora de inicio.
|
||||||
|
|
||||||
|
### 🔔 Recordatorio Pre-Clase *(Nuevo en v1.1.6)*
|
||||||
|
- Cada vez que la app sincroniza la cartelera, programa automáticamente una notificación local **30 minutos antes** del inicio de cada materia favorita del día.
|
||||||
|
- Usa IDs de notificación estables para evitar duplicados al sincronizar múltiples veces.
|
||||||
|
- Funciona completamente **offline** — no requiere servidor externo.
|
||||||
|
|
||||||
|
### 🪪 Credencial Digital Universitaria
|
||||||
|
- Inicio de sesión directo al Campus Virtual (Moodle) con DNI y clave.
|
||||||
|
- **Sin intermediarios**: las credenciales nunca pasan por ningún servidor tercero. El flujo HTTP se delega al motor nativo del SO mediante `CapacitorHttp`, evitando bloqueos CORS.
|
||||||
|
- Descarga y renderiza el PDF de la credencial (código QR) como imagen nativa usando `pdfjs-dist`.
|
||||||
|
- La imagen queda **cacheada localmente** para usarla offline sin volver a loguearse.
|
||||||
|
- El brillo de la pantalla aumenta automáticamente al mostrar el QR para facilitar su lectura.
|
||||||
|
- Login completamente **opcional** — la cartelera funciona sin necesidad de cuenta.
|
||||||
|
|
||||||
|
### 🛡️ Privacidad y Seguridad
|
||||||
|
- No se recopilan nombres, contraseñas ni correos.
|
||||||
|
- No hay publicidad, telemetría ni analytics de terceros.
|
||||||
|
- Todo el procesamiento ocurre en la memoria del teléfono.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Compatibilidad Multiplataforma (iOS y Android)
|
## 🛠️ Información Técnica y Frameworks
|
||||||
|
|
||||||
Todo el código fuente de Aulas UNICABA (TypeScript, HTML y SCSS) y la selección estricta de sus plugins de Capacitor son **100% compatibles tanto con equipos móviles Android como con el ecosistema iOS de Apple**. La arquitectura garantiza que la aplicación operará sintácticamente de forma idéntica en iPhone.
|
| Componente | Tecnología |
|
||||||
|
|---|---|
|
||||||
|
| Base Frontend | [Angular 20.3.x](https://angular.io/) |
|
||||||
|
| UI Components | [Ionic Framework v8](https://ionicframework.com/) |
|
||||||
|
| Puente Nativo | [Capacitor v8](https://capacitorjs.com/) |
|
||||||
|
| Renderizado PDF | `pdfjs-dist` |
|
||||||
|
|
||||||
|
### Plugins de Capacitor utilizados
|
||||||
|
|
||||||
|
| Plugin | Uso |
|
||||||
|
|---|---|
|
||||||
|
| `@capacitor/filesystem` | Escritura y lectura del CSV de respaldo en "Documentos" |
|
||||||
|
| `@capawesome/capacitor-file-picker` | Importación manual de CSV desde el explorador de archivos |
|
||||||
|
| `@capacitor/local-notifications` | Avisos de cambio de aula y recordatorios pre-clase |
|
||||||
|
| `@capacitor/preferences` | Caché rápida de horarios y credencial |
|
||||||
|
| `@transistorsoft/capacitor-background-fetch` | Sincronización silenciosa en background |
|
||||||
|
| `@capacitor-community/screen-brightness` | Máximo brillo al mostrar la credencial QR |
|
||||||
|
| `@capacitor/haptics` | Feedback háptico en acciones clave |
|
||||||
|
| `@capacitor/share` | Compartir horarios por WhatsApp, Telegram, etc. |
|
||||||
|
| `@capacitor/network` | Detección de estado de conexión |
|
||||||
|
| `@capacitor/clipboard` | Acceso al portapapeles del sistema |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 Compatibilidad Multiplataforma
|
||||||
|
|
||||||
|
Todo el código fuente (TypeScript, HTML y SCSS) y los plugins seleccionados son **100% compatibles con Android e iOS**. La arquitectura garantiza comportamiento idéntico en ambas plataformas.
|
||||||
|
|
||||||
### 🤖 Despliegue en Android
|
### 🤖 Despliegue en Android
|
||||||
|
|
||||||
**Requisito previo**: [Android Studio](https://developer.android.com/studio) instalado, Node.js y NPM.
|
**Requisito previo**: [Android Studio](https://developer.android.com/studio) instalado, Node.js y NPM.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Instalar dependencias e hibridación
|
# 1. Instalar dependencias
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 2. Compilación del binario web
|
# 2. Compilación del bundle web
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# 3. Transpilación y Sincronización de puentes nativos Android
|
# 3. Sincronización con puentes nativos
|
||||||
npx cap sync android
|
npx cap sync android
|
||||||
|
|
||||||
# 4. Apertura del EDI para firma de APK o ejecución en celular por ADB
|
# 4. Abrir en Android Studio para firmar APK o ejecutar por ADB
|
||||||
npx cap open android
|
npx cap open android
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🍏 Despliegue en iOS (iPhone / iPad)
|
### 🍏 Despliegue en iOS (iPhone / iPad)
|
||||||
**Requisito previo**: Una computadora macOS reciente, [Xcode](https://developer.apple.com/xcode/), Node.js y CocoaPods instalado globalmente (`sudo gem install cocoapods`).
|
|
||||||
|
**Requisito previo**: macOS reciente, [Xcode](https://developer.apple.com/xcode/), Node.js y CocoaPods (`sudo gem install cocoapods`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1-2. Instalación y compilación estándar de web
|
# 1-2. Instalación y compilación web
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# 3. Empaquetar el proyecto inicial para iOS (Solo se ejecuta una vez en la vida del proyecto)
|
# 3. Agregar plataforma iOS (solo la primera vez)
|
||||||
npx cap add ios
|
npx cap add ios
|
||||||
|
|
||||||
# 4. Sincronizar plugins y actualizar Pods
|
# 4. Sincronizar plugins y actualizar Pods
|
||||||
npx cap sync ios
|
npx cap sync ios
|
||||||
|
|
||||||
# 5. Apertura en Xcode para configurar equipo de Apple Dev y emular
|
# 5. Abrir en Xcode
|
||||||
npx cap open ios
|
npx cap open ios
|
||||||
```
|
```
|
||||||
> **Nota para Xcode:** Para compilar exitosamente deberás habilitar permisos explícitos para Local Notifications y capacidades de "Background Fetch" en los *Background Modes* de la pestaña *Signing & Capabilities* dentro de Xcode.
|
|
||||||
|
> **Nota para Xcode:** Habilitar permisos explícitos para **Local Notifications** y **Background Fetch** en la pestaña *Signing & Capabilities* de Xcode.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Privacidad y Flujo Cero Intermediarios (Zero-Trust)
|
## 🔒 Privacidad y Flujo Cero Intermediarios (Zero-Trust)
|
||||||
1. **Credenciales en Moodle Bypass**: Por diseño de arquitectura, la app extrae el PDF de credencial estudiantil logueándose de manera asilvestrada mediante `CapacitorHttp`. Supera bloqueos tipo CORS al delegar el flujo de HTTP al motor OS, nunca triangulando el DNI a través de un backend tercero.
|
|
||||||
2. **Resiliencia (Anti-Wipes)**: Dado las agresivas políticas *Storage Scoped* a partir de Android 11 que borran bases de datos SQLite y Preferences locales si se desinstala, la app empuja automáticamente la personalización del alumno a un archivo estandarizado `aulas_unicaba_datos.csv` a su gestor público de archivos general ("Documentos").
|
1. **Credenciales en Moodle Bypass**: La app extrae el PDF de credencial logueándose directamente mediante `CapacitorHttp`, delegando el flujo HTTP al motor nativo del OS. Nunca triangula el DNI a través de un backend tercero.
|
||||||
|
2. **Resiliencia Anti-Wipes**: Las agresivas políticas *Storage Scoped* de Android 11+ pueden borrar bases de datos SQLite y Preferences al desinstalar. La app mitiga esto exportando automáticamente los favoritos a `aulas_unicaba_datos.csv` en el gestor público de archivos ("Documentos").
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "ar.edu.unicaba.cartelera"
|
applicationId "ar.edu.unicaba.cartelera"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 8
|
versionCode 12
|
||||||
versionName "1.1.5"
|
versionName "1.1.9"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
+2
-2
@@ -49,8 +49,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "2kb",
|
"maximumWarning": "6kb",
|
||||||
"maximumError": "4kb"
|
"maximumError": "8kb"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
|
|||||||
Generated
+43
-110
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aulas-unicaba",
|
"name": "aulas-unicaba",
|
||||||
"version": "1.1.4",
|
"version": "1.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aulas-unicaba",
|
"name": "aulas-unicaba",
|
||||||
"version": "1.1.4",
|
"version": "1.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "~20.3.18",
|
"@angular/animations": "~20.3.18",
|
||||||
"@angular/common": "~20.3.18",
|
"@angular/common": "~20.3.18",
|
||||||
@@ -600,6 +600,7 @@
|
|||||||
"integrity": "sha512-CVskZnF38IIxVVlKWi1VCz7YH/gHMJu2IY9bD1AVoBBGIe0xA4FRXJkW2Y+EDs9vQqZTkZZljhK5gL65Ro1PeQ==",
|
"integrity": "sha512-CVskZnF38IIxVVlKWi1VCz7YH/gHMJu2IY9bD1AVoBBGIe0xA4FRXJkW2Y+EDs9vQqZTkZZljhK5gL65Ro1PeQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular-eslint/bundled-angular-compiler": "20.7.0",
|
"@angular-eslint/bundled-angular-compiler": "20.7.0",
|
||||||
"eslint-scope": "^9.0.0"
|
"eslint-scope": "^9.0.0"
|
||||||
@@ -629,6 +630,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.18.tgz",
|
||||||
"integrity": "sha512-XFxgSyjfs0SRD2vQVFJljmM4z9nTvUoI8TRqSre/+l8D2FgzD5pG67Aj2BgDgpSFAUkIcI37G48ijK7a3ZZ3WA==",
|
"integrity": "sha512-XFxgSyjfs0SRD2vQVFJljmM4z9nTvUoI8TRqSre/+l8D2FgzD5pG67Aj2BgDgpSFAUkIcI37G48ijK7a3ZZ3WA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -778,6 +780,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.18.tgz",
|
||||||
"integrity": "sha512-M62oQbSTRmnGavIVCwimoadg/PDWadgNhactMm9fgH0eM9rx+iWBAYJk4VufO0bwOhysFpRZpJgXlFjOifz/Jw==",
|
"integrity": "sha512-M62oQbSTRmnGavIVCwimoadg/PDWadgNhactMm9fgH0eM9rx+iWBAYJk4VufO0bwOhysFpRZpJgXlFjOifz/Jw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -794,6 +797,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.18.tgz",
|
||||||
"integrity": "sha512-AaP/LCiDNcYmF135EEozjyR04NRBT38ZfBHQwjhgwiBBTejmvcpHwJaHSkraLpZqZzE4BQqqmgiQ1EJqxEwLVA==",
|
"integrity": "sha512-AaP/LCiDNcYmF135EEozjyR04NRBT38ZfBHQwjhgwiBBTejmvcpHwJaHSkraLpZqZzE4BQqqmgiQ1EJqxEwLVA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -807,6 +811,7 @@
|
|||||||
"integrity": "sha512-zsoEgLgnblmRbi47YwMghKirJ8IBKJ3+I8TxLBRIBrhx+KHFp+6oeDeLyu9H+djdyk88zexVd09wzR/YK73F0g==",
|
"integrity": "sha512-zsoEgLgnblmRbi47YwMghKirJ8IBKJ3+I8TxLBRIBrhx+KHFp+6oeDeLyu9H+djdyk88zexVd09wzR/YK73F0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "7.28.3",
|
"@babel/core": "7.28.3",
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||||
@@ -839,6 +844,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.18.tgz",
|
||||||
"integrity": "sha512-B+NQQngd/aDbcfW0zGLis3wTLDeHTeTYMl/mGKQH+HwdPaRCKI1wEtaXaOYVJXkP2FeThocPevB8gLwNlPQUUw==",
|
"integrity": "sha512-B+NQQngd/aDbcfW0zGLis3wTLDeHTeTYMl/mGKQH+HwdPaRCKI1wEtaXaOYVJXkP2FeThocPevB8gLwNlPQUUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -864,6 +870,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.18.tgz",
|
||||||
"integrity": "sha512-x6/99LfxolyZIFUL3Wr0OrtuXHEDwEz/rwx+WzE7NL+n35yO40t3kp0Sn5uMFwI94i91QZJmXHltMpZhrVLuYg==",
|
"integrity": "sha512-x6/99LfxolyZIFUL3Wr0OrtuXHEDwEz/rwx+WzE7NL+n35yO40t3kp0Sn5uMFwI94i91QZJmXHltMpZhrVLuYg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -892,6 +899,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.18.tgz",
|
||||||
"integrity": "sha512-q6s5rEN1yYazpHYp+k4pboXRzMsRB9auzTRBEhyXSGYxqzrnn3qHN0DqgsLC9WAdyhCgnIEMFA8kRT+W277DqQ==",
|
"integrity": "sha512-q6s5rEN1yYazpHYp+k4pboXRzMsRB9auzTRBEhyXSGYxqzrnn3qHN0DqgsLC9WAdyhCgnIEMFA8kRT+W277DqQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -932,6 +940,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.18.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.18.tgz",
|
||||||
"integrity": "sha512-3CWejsEYr+ze+ktvWN/qHdyq5WLrj96QZpGYJyxh1pchIcpMPE9MmLpdjf0CUrWYB7g/85u0Geq/xsz72JrGng==",
|
"integrity": "sha512-3CWejsEYr+ze+ktvWN/qHdyq5WLrj96QZpGYJyxh1pchIcpMPE9MmLpdjf0CUrWYB7g/85u0Geq/xsz72JrGng==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
@@ -976,6 +985,7 @@
|
|||||||
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
@@ -2825,6 +2835,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.2.0.tgz",
|
||||||
"integrity": "sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==",
|
"integrity": "sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -3947,6 +3958,7 @@
|
|||||||
"integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==",
|
"integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inquirer/checkbox": "^4.2.1",
|
"@inquirer/checkbox": "^4.2.1",
|
||||||
"@inquirer/confirm": "^5.1.14",
|
"@inquirer/confirm": "^5.1.14",
|
||||||
@@ -5267,9 +5279,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5290,9 +5299,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5313,9 +5319,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5336,9 +5339,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5359,9 +5359,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5559,9 +5556,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5579,9 +5573,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5599,9 +5590,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5619,9 +5607,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5639,9 +5624,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5659,9 +5641,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5679,9 +5658,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6209,9 +6185,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6233,9 +6206,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6257,9 +6227,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6281,9 +6248,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6305,9 +6269,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6329,9 +6290,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6519,9 +6477,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6536,9 +6491,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6552,9 +6504,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6568,9 +6517,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6585,9 +6531,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6602,9 +6545,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6619,9 +6559,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6636,9 +6573,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6653,9 +6587,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6670,9 +6601,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6687,9 +6615,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6703,9 +6628,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6719,9 +6641,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7162,6 +7081,7 @@
|
|||||||
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
@@ -7302,6 +7222,7 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -7409,6 +7330,7 @@
|
|||||||
"integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
|
"integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
},
|
},
|
||||||
@@ -7481,6 +7403,7 @@
|
|||||||
"integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
|
"integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
@@ -7765,6 +7688,7 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -7840,6 +7764,7 @@
|
|||||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -8538,6 +8463,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -10197,6 +10123,7 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -10689,6 +10616,7 @@
|
|||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -11477,6 +11405,7 @@
|
|||||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -12559,7 +12488,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.1.2.tgz",
|
||||||
"integrity": "sha512-2oIUMGn00FdUiqz6epiiJr7xcFyNYj3rDcfmnzfkBnHyBQ3cBQUs4mmyGsOb7TTLb9kxk7dBcmEmqhDKkBoDyA==",
|
"integrity": "sha512-2oIUMGn00FdUiqz6epiiJr7xcFyNYj3rDcfmnzfkBnHyBQ3cBQUs4mmyGsOb7TTLb9kxk7dBcmEmqhDKkBoDyA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/jasmine-spec-reporter": {
|
"node_modules/jasmine-spec-reporter": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
@@ -12608,6 +12538,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -12748,6 +12679,7 @@
|
|||||||
"integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==",
|
"integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@colors/colors": "1.5.0",
|
"@colors/colors": "1.5.0",
|
||||||
"body-parser": "^1.19.0",
|
"body-parser": "^1.19.0",
|
||||||
@@ -13325,6 +13257,7 @@
|
|||||||
"integrity": "sha512-kdTwsyRuncDfjEs0DlRILWNvxhDG/Zij4YLO4TMJgDLW+8OzpfkdPnRgrsRuY1o+oaxJGWsps5f/RVBgGmmN0w==",
|
"integrity": "sha512-kdTwsyRuncDfjEs0DlRILWNvxhDG/Zij4YLO4TMJgDLW+8OzpfkdPnRgrsRuY1o+oaxJGWsps5f/RVBgGmmN0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"copy-anything": "^2.0.1",
|
"copy-anything": "^2.0.1",
|
||||||
"parse-node-version": "^1.0.1",
|
"parse-node-version": "^1.0.1",
|
||||||
@@ -13469,6 +13402,7 @@
|
|||||||
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
|
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cli-truncate": "^4.0.0",
|
"cli-truncate": "^4.0.0",
|
||||||
"colorette": "^2.0.20",
|
"colorette": "^2.0.20",
|
||||||
@@ -15366,6 +15300,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -16063,9 +15998,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -16080,9 +16012,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -16097,9 +16026,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -16114,9 +16040,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -16210,6 +16133,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.1.0"
|
"tslib": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -16303,6 +16227,7 @@
|
|||||||
"integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==",
|
"integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.0",
|
"chokidar": "^4.0.0",
|
||||||
"immutable": "^5.0.2",
|
"immutable": "^5.0.2",
|
||||||
@@ -17445,6 +17370,7 @@
|
|||||||
"integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==",
|
"integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/source-map": "^0.3.3",
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
"acorn": "^8.14.0",
|
"acorn": "^8.14.0",
|
||||||
@@ -17646,7 +17572,8 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tuf-js": {
|
"node_modules/tuf-js": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
@@ -17782,6 +17709,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -18047,6 +17975,7 @@
|
|||||||
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -18181,6 +18110,7 @@
|
|||||||
"integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==",
|
"integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/eslint-scope": "^3.7.7",
|
"@types/eslint-scope": "^3.7.7",
|
||||||
"@types/estree": "^1.0.8",
|
"@types/estree": "^1.0.8",
|
||||||
@@ -18283,6 +18213,7 @@
|
|||||||
"integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==",
|
"integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/bonjour": "^3.5.13",
|
"@types/bonjour": "^3.5.13",
|
||||||
"@types/connect-history-api-fallback": "^1.5.4",
|
"@types/connect-history-api-fallback": "^1.5.4",
|
||||||
@@ -19265,6 +19196,7 @@
|
|||||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -19283,7 +19215,8 @@
|
|||||||
"version": "0.15.1",
|
"version": "0.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
|
||||||
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
|
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aulas-unicaba",
|
"name": "aulas-unicaba",
|
||||||
"version": "1.1.5",
|
"version": "1.1.9",
|
||||||
"author": "Ionic Framework",
|
"author": "Ionic Framework",
|
||||||
"homepage": "https://ionicframework.com/",
|
"homepage": "https://ionicframework.com/",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<ion-header>
|
||||||
|
<ion-toolbar color="primary">
|
||||||
|
<ion-title>Agenda Académica</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button (click)="dismiss()">Cerrar</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content class="ion-padding" color="light">
|
||||||
|
<div class="materia-info">
|
||||||
|
<h2>{{ materia.asignatura }}</h2>
|
||||||
|
<p>Código: {{ materia.codigo }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ion-button expand="block" (click)="abrirEditor()" class="add-btn">
|
||||||
|
<ion-icon name="add-circle-outline" slot="start"></ion-icon>
|
||||||
|
Agendar Nuevo Evento
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<div class="empty-state" *ngIf="eventos.length === 0">
|
||||||
|
<ion-icon name="calendar-clear-outline"></ion-icon>
|
||||||
|
<p>No hay eventos programados.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ion-list *ngIf="eventos.length > 0" class="agenda-list">
|
||||||
|
<ion-item *ngFor="let ev of eventos" button (click)="abrirEditor(ev)" lines="full" class="agenda-item">
|
||||||
|
<div class="item-content">
|
||||||
|
<div class="item-header">
|
||||||
|
<ion-badge color="secondary">{{ ev.tipo }}</ion-badge>
|
||||||
|
</div>
|
||||||
|
<div class="item-details">
|
||||||
|
<p class="fecha-hora">
|
||||||
|
<ion-icon name="calendar-outline"></ion-icon> {{ formatFecha(ev.fecha) }}
|
||||||
|
<ion-icon name="time-outline" class="icon-space"></ion-icon> {{ formatHora(ev.fecha) }} hs
|
||||||
|
</p>
|
||||||
|
<p class="aula" *ngIf="ev.aula">
|
||||||
|
<ion-icon name="location-outline"></ion-icon> Aula: {{ ev.aula }}
|
||||||
|
</p>
|
||||||
|
<p class="notas" *ngIf="ev.notas">
|
||||||
|
{{ ev.notas }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ion-icon name="chevron-forward-outline" slot="end" color="medium"></ion-icon>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
</ion-content>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
.materia-info {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-text-color);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
--border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 40px;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-list {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agenda-item {
|
||||||
|
--background: var(--ion-card-background, #ffffff);
|
||||||
|
--border-radius: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
|
||||||
|
.item-content {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details {
|
||||||
|
p {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ion-text-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
margin-right: 6px;
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.notas {
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-space {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AgendaModalComponent } from './agenda-modal.component';
|
||||||
|
|
||||||
|
describe('AgendaModalComponent', () => {
|
||||||
|
let component: AgendaModalComponent;
|
||||||
|
let fixture: ComponentFixture<AgendaModalComponent>;
|
||||||
|
|
||||||
|
beforeEach(waitForAsync(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [AgendaModalComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AgendaModalComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { IonicModule, ModalController } from '@ionic/angular';
|
||||||
|
import { EventoAcademico, ClaseHorario } from '../../services/data';
|
||||||
|
import { EventosService } from '../../services/eventos.service';
|
||||||
|
import { EventoModalComponent } from '../evento-modal/evento-modal.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-agenda-modal',
|
||||||
|
templateUrl: './agenda-modal.component.html',
|
||||||
|
styleUrls: ['./agenda-modal.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, IonicModule]
|
||||||
|
})
|
||||||
|
export class AgendaModalComponent implements OnInit {
|
||||||
|
@Input() materia!: ClaseHorario;
|
||||||
|
eventos: EventoAcademico[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private modalCtrl: ModalController,
|
||||||
|
private eventosService: EventosService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.cargarEventos();
|
||||||
|
}
|
||||||
|
|
||||||
|
ionViewWillEnter() {
|
||||||
|
this.cargarEventos();
|
||||||
|
}
|
||||||
|
|
||||||
|
cargarEventos() {
|
||||||
|
this.eventos = this.eventosService.getEventosByMateria(this.materia.codigo)
|
||||||
|
.sort((a, b) => new Date(a.fecha).getTime() - new Date(b.fecha).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
this.modalCtrl.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFecha(fechaIso: string): string {
|
||||||
|
const d = new Date(fechaIso);
|
||||||
|
return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
formatHora(fechaIso: string): string {
|
||||||
|
const d = new Date(fechaIso);
|
||||||
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async abrirEditor(evento?: EventoAcademico) {
|
||||||
|
const modal = await this.modalCtrl.create({
|
||||||
|
component: EventoModalComponent,
|
||||||
|
componentProps: {
|
||||||
|
materiaCodigo: this.materia.codigo,
|
||||||
|
materiaNombre: this.materia.asignatura,
|
||||||
|
eventoExistente: evento
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await modal.present();
|
||||||
|
|
||||||
|
const { role } = await modal.onWillDismiss();
|
||||||
|
if (role === 'saved' || role === 'deleted') {
|
||||||
|
this.cargarEventos();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<ion-header>
|
||||||
|
<ion-toolbar color="primary">
|
||||||
|
<ion-title>{{ isEdit ? 'Editar' : 'Programar' }} Hito</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button (click)="dismiss()">Cerrar</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content class="ion-padding">
|
||||||
|
<div class="materia-info">
|
||||||
|
<small>Materia:</small>
|
||||||
|
<h3>{{ materiaNombre }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ion-list lines="inset" class="compact-list">
|
||||||
|
<ion-item class="input-item">
|
||||||
|
<ion-label position="stacked">Tipo e Hito</ion-label>
|
||||||
|
<ion-select [(ngModel)]="tipo" interface="popover">
|
||||||
|
<ion-select-option value="Parcial 1">Parcial 1</ion-select-option>
|
||||||
|
<ion-select-option value="Parcial 2">Parcial 2</ion-select-option>
|
||||||
|
<ion-select-option value="TP 1">Trabajo Práctico 1</ion-select-option>
|
||||||
|
<ion-select-option value="TP 2">Trabajo Práctico 2</ion-select-option>
|
||||||
|
<ion-select-option value="TP 3">Trabajo Práctico 3</ion-select-option>
|
||||||
|
<ion-select-option value="Recu. P1">Recuperatorio Parcial 1</ion-select-option>
|
||||||
|
<ion-select-option value="Recu. P2">Recuperatorio Parcial 2</ion-select-option>
|
||||||
|
<ion-select-option value="Recu. TP 1">Recuperatorio TP 1</ion-select-option>
|
||||||
|
<ion-select-option value="Recu. TP 2">Recuperatorio TP 2</ion-select-option>
|
||||||
|
<ion-select-option value="Recu. TP 3">Recuperatorio TP 3</ion-select-option>
|
||||||
|
<ion-select-option value="Final">Final</ion-select-option>
|
||||||
|
<ion-select-option value="Otro">Otro</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<ion-item class="input-item">
|
||||||
|
<ion-label position="stacked">Fecha y Hora</ion-label>
|
||||||
|
<ion-datetime-button datetime="datetime"></ion-datetime-button>
|
||||||
|
<ion-modal [keepContentsMounted]="true">
|
||||||
|
<ng-template>
|
||||||
|
<ion-datetime id="datetime" [(ngModel)]="fecha" presentation="date-time" [showDefaultButtons]="true"
|
||||||
|
doneText="OK" cancelText="Cancelar"></ion-datetime>
|
||||||
|
</ng-template>
|
||||||
|
</ion-modal>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<ion-grid class="ion-no-padding">
|
||||||
|
<ion-row>
|
||||||
|
<ion-col size="6">
|
||||||
|
<ion-item class="input-item side-item">
|
||||||
|
<ion-label position="stacked">Aula / Zoom</ion-label>
|
||||||
|
<ion-input type="text" [(ngModel)]="aula" placeholder="Ej: 102"></ion-input>
|
||||||
|
</ion-item>
|
||||||
|
</ion-col>
|
||||||
|
<ion-col size="6">
|
||||||
|
<ion-item class="input-item side-item">
|
||||||
|
<ion-label position="stacked">Link</ion-label>
|
||||||
|
<ion-input type="url" [(ngModel)]="link" placeholder="https://"></ion-input>
|
||||||
|
</ion-item>
|
||||||
|
</ion-col>
|
||||||
|
</ion-row>
|
||||||
|
</ion-grid>
|
||||||
|
|
||||||
|
<ion-item class="input-item">
|
||||||
|
<ion-label position="stacked">Notas</ion-label>
|
||||||
|
<ion-textarea [(ngModel)]="notas" placeholder="Breve descripción..." rows="2"></ion-textarea>
|
||||||
|
</ion-item>
|
||||||
|
</ion-list>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="actions-container">
|
||||||
|
<ion-button expand="block" color="secondary" (click)="guardar()" class="main-btn">
|
||||||
|
{{ isEdit ? 'Actualizar Agenda' : 'Agendar Hito' }}
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<ion-button *ngIf="isEdit" expand="block" fill="clear" color="danger" (click)="eliminar()" class="delete-btn">
|
||||||
|
<ion-icon name="trash-outline" slot="start"></ion-icon>
|
||||||
|
Eliminar Evento
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</ion-content>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
.materia-info {
|
||||||
|
margin-top: 10px;
|
||||||
|
h3 {
|
||||||
|
margin: 5px 0 20px;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-item {
|
||||||
|
--padding-start: 10px;
|
||||||
|
--padding-end: 10px;
|
||||||
|
--padding-top: 5px;
|
||||||
|
--padding-bottom: 5px;
|
||||||
|
--min-height: 45px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--ion-color-light);
|
||||||
|
|
||||||
|
ion-label {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px !important;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-item {
|
||||||
|
margin-left: 2px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-list {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ion-datetime-button {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-container {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-btn {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
--color: var(--ion-color-danger);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { IonicModule, ModalController } from '@ionic/angular';
|
||||||
|
import { EventoAcademico } from '../../services/data';
|
||||||
|
import { EventosService } from '../../services/eventos.service';
|
||||||
|
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-evento-modal',
|
||||||
|
templateUrl: './evento-modal.component.html',
|
||||||
|
styleUrls: ['./evento-modal.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, IonicModule, FormsModule]
|
||||||
|
})
|
||||||
|
export class EventoModalComponent implements OnInit {
|
||||||
|
@Input() materiaCodigo!: string;
|
||||||
|
@Input() materiaNombre!: string;
|
||||||
|
@Input() eventoExistente?: EventoAcademico;
|
||||||
|
|
||||||
|
tipo: 'Parcial' | 'TP' | 'Final' | 'Otro' = 'Parcial';
|
||||||
|
fecha: string = new Date().toISOString();
|
||||||
|
aula: string = '';
|
||||||
|
link: string = '';
|
||||||
|
notas: string = '';
|
||||||
|
|
||||||
|
isEdit: boolean = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private modalCtrl: ModalController,
|
||||||
|
private eventosService: EventosService
|
||||||
|
) { }
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
if (this.eventoExistente) {
|
||||||
|
this.isEdit = true;
|
||||||
|
this.tipo = this.eventoExistente.tipo;
|
||||||
|
this.fecha = this.eventoExistente.fecha;
|
||||||
|
this.aula = this.eventoExistente.aula || '';
|
||||||
|
this.link = this.eventoExistente.link || '';
|
||||||
|
this.notas = this.eventoExistente.notas || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
this.modalCtrl.dismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
async guardar() {
|
||||||
|
const nuevoEvento: EventoAcademico = {
|
||||||
|
id: this.isEdit ? this.eventoExistente!.id : Date.now().toString(),
|
||||||
|
materiaCodigo: this.materiaCodigo,
|
||||||
|
tipo: this.tipo,
|
||||||
|
fecha: this.fecha,
|
||||||
|
aula: this.aula,
|
||||||
|
link: this.link,
|
||||||
|
notas: this.notas
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.eventosService.saveEvento(nuevoEvento);
|
||||||
|
this.modalCtrl.dismiss(nuevoEvento, 'saved');
|
||||||
|
}
|
||||||
|
|
||||||
|
async eliminar() {
|
||||||
|
if (this.eventoExistente) {
|
||||||
|
await this.eventosService.deleteEvento(this.eventoExistente.id);
|
||||||
|
this.modalCtrl.dismiss(null, 'deleted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { HomePage } from './home.page';
|
|||||||
|
|
||||||
import { HomePageRoutingModule } from './home-routing.module';
|
import { HomePageRoutingModule } from './home-routing.module';
|
||||||
import { CredencialModalComponent } from '../components/credencial-modal/credencial-modal.component';
|
import { CredencialModalComponent } from '../components/credencial-modal/credencial-modal.component';
|
||||||
|
import { EventoModalComponent } from '../components/evento-modal/evento-modal.component';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
@@ -14,7 +16,8 @@ import { CredencialModalComponent } from '../components/credencial-modal/credenc
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
IonicModule,
|
IonicModule,
|
||||||
HomePageRoutingModule,
|
HomePageRoutingModule,
|
||||||
CredencialModalComponent
|
CredencialModalComponent,
|
||||||
|
EventoModalComponent
|
||||||
],
|
],
|
||||||
declarations: [HomePage]
|
declarations: [HomePage]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
</div>
|
</div>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
|
|
||||||
<ion-toolbar color="light" class="ion-no-padding">
|
<ion-toolbar color="light" class="ion-no-padding">
|
||||||
<div class="view-segment">
|
<div class="view-segment">
|
||||||
<ion-segment [(ngModel)]="viewMode" (ionChange)="onViewModeChange()" color="primary">
|
<ion-segment [(ngModel)]="viewMode" (ionChange)="onViewModeChange()" color="primary">
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
realiza en la memoria de tu teléfono.</p>
|
realiza en la memoria de tu teléfono.</p>
|
||||||
<p><strong>Sin Rastreadores:</strong> No incluimos publicidad, telemetría ni analíticas de terceros.</p>
|
<p><strong>Sin Rastreadores:</strong> No incluimos publicidad, telemetría ni analíticas de terceros.</p>
|
||||||
|
|
||||||
<p class="ion-text-center"><small>Versión 1.1.5 - 25/03/2026</small></p>
|
<p class="ion-text-center"><small>Versión 1.1.9 - 26/03/2026</small></p>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ion-modal>
|
</ion-modal>
|
||||||
@@ -82,6 +82,38 @@
|
|||||||
refreshingText="Actualizando..."></ion-refresher-content>
|
refreshingText="Actualizando..."></ion-refresher-content>
|
||||||
</ion-refresher>
|
</ion-refresher>
|
||||||
|
|
||||||
|
<!-- Banner: Próxima Clase -->
|
||||||
|
<div class="proxima-clase-container" *ngIf="viewMode === 'favoritos' && proximaClase && !loading">
|
||||||
|
<ion-card class="proxima-clase-card" [class.urgente]="proximaClase.diffMs > 0 && proximaClase.diffMs <= 1800000">
|
||||||
|
<ion-card-content (click)="verAgendaMateria(proximaClase.item)">
|
||||||
|
<div class="proxima-header">
|
||||||
|
<div class="proxima-label">
|
||||||
|
<ion-icon name="time-outline"></ion-icon>
|
||||||
|
<span>{{ proximaClase.label }}</span>
|
||||||
|
</div>
|
||||||
|
<ion-chip [color]="countdownColor(proximaClase.diffMs)" class="countdown-chip"
|
||||||
|
[class.pulse-chip]="proximaClase.diffMs > 0 && proximaClase.diffMs <= 1800000">
|
||||||
|
<ion-icon name="alarm-outline"></ion-icon>
|
||||||
|
<ion-label>{{ formatCountdown(proximaClase.diffMs) }}</ion-label>
|
||||||
|
</ion-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="proxima-asignatura">{{ proximaClase.item.asignatura }}</p>
|
||||||
|
<div class="proxima-detalles">
|
||||||
|
<span class="proxima-aula">
|
||||||
|
<ion-icon name="location-outline"></ion-icon>
|
||||||
|
Aula {{ proximaClase.item.aula }}
|
||||||
|
</span>
|
||||||
|
<span class="proxima-hora">
|
||||||
|
<ion-icon name="calendar-outline"></ion-icon>
|
||||||
|
{{ proximaClase.item.dia }} {{ proximaClase.item.horario }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ion-card-content>
|
||||||
|
</ion-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="loading-container" *ngIf="loading">
|
<div class="loading-container" *ngIf="loading">
|
||||||
<ion-spinner name="crescent" color="primary"></ion-spinner>
|
<ion-spinner name="crescent" color="primary"></ion-spinner>
|
||||||
<p>Cargando cartelera...</p>
|
<p>Cargando cartelera...</p>
|
||||||
@@ -102,26 +134,43 @@
|
|||||||
<ion-card-title>{{ item.asignatura }}</ion-card-title>
|
<ion-card-title>{{ item.asignatura }}</ion-card-title>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
|
<!-- Compartir siempre visible -->
|
||||||
<ion-icon class="share-icon" name="share-social-outline" color="medium"
|
<ion-icon class="share-icon" name="share-social-outline" color="medium"
|
||||||
(click)="shareItem(item)"></ion-icon>
|
(click)="shareItem(item)"></ion-icon>
|
||||||
<ion-icon class="fav-icon" [name]="isFavorito(item.codigo) ? 'star' : 'star-outline'"
|
|
||||||
[color]="isFavorito(item.codigo) ? 'warning' : 'medium'" (click)="toggleFavorito(item)"></ion-icon>
|
<!-- Icono condicional: Hamburguesa en favoritos, Estrella en cartelera -->
|
||||||
|
<ion-icon class="menu-icon"
|
||||||
|
[name]="viewMode === 'favoritos' ? 'menu-outline' : (isFavorito(item.codigo) ? 'star' : 'star-outline')"
|
||||||
|
[color]="(viewMode === 'todas' && isFavorito(item.codigo)) ? 'warning' : 'medium'"
|
||||||
|
(click)="openMateriaActions(item)"></ion-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Indicador de Próximo Evento (Agenda) -->
|
||||||
|
<div class="evento-badge-container" *ngIf="getProximoEvento(item.codigo) as proximo">
|
||||||
|
<ion-badge [color]="isEventoUrgente(proximo.fecha) ? 'danger' : 'secondary'" class="evento-badge">
|
||||||
|
<ion-icon [name]="proximo.tipo === 'TP' ? 'document-text-outline' : 'calendar-outline'"></ion-icon>
|
||||||
|
{{ proximo.tipo }}: {{ formatFechaEvento(proximo.fecha) }}
|
||||||
|
</ion-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
</ion-card-header>
|
</ion-card-header>
|
||||||
<ion-card-content>
|
<ion-card-content (click)="verAgendaMateria(item)">
|
||||||
<p><strong>Código:</strong> {{ item.codigo }}</p>
|
<p><strong>Código:</strong> {{ item.codigo }}</p>
|
||||||
<div class="aula-container">
|
<div class="aula-container">
|
||||||
<span class="aula-tag">{{ item.aula }}</span>
|
<span class="aula-tag">{{ item.aula }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="schedule-text">{{ item.dia }} - {{ item.horario }}</p>
|
<p class="schedule-text">{{ item.dia }} - {{ item.horario }}</p>
|
||||||
<div *ngIf="item.link" class="link-container">
|
<div *ngIf="item.link" class="link-container">
|
||||||
<ion-button fill="outline" color="primary" size="small" expand="block" (click)="openLink(item.link!)">
|
<ion-button fill="outline" color="primary" size="small" expand="block"
|
||||||
|
(click)="openLink(item.link!); $event.stopPropagation()">
|
||||||
<ion-icon slot="start" name="videocam-outline"></ion-icon>
|
<ion-icon slot="start" name="videocam-outline"></ion-icon>
|
||||||
Ingresar a Clase Virtual
|
Ingresar a Clase Virtual
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
</ion-card-content>
|
</ion-card-content>
|
||||||
|
|
||||||
</ion-card>
|
</ion-card>
|
||||||
</ion-list>
|
</ion-list>
|
||||||
|
|
||||||
@@ -133,9 +182,12 @@
|
|||||||
|
|
||||||
<ion-footer>
|
<ion-footer>
|
||||||
<ion-toolbar color="primary" class="ion-no-padding">
|
<ion-toolbar color="primary" class="ion-no-padding">
|
||||||
<div class="footer-content" (click)="openLink('https://www.google.com/maps/place/UdelaCiudad/data=!4m2!3m1!1s0x0:0x6dab35ab5ab98245?sa=X&ved=1t:2428&ictx=111')" style="cursor: pointer;" title="Mostrar en el mapa">
|
<div class="footer-content"
|
||||||
|
(click)="openLink('https://www.google.com/maps/place/UdelaCiudad/data=!4m2!3m1!1s0x0:0x6dab35ab5ab98245?sa=X&ved=1t:2428&ictx=111')"
|
||||||
|
style="cursor: pointer;" title="Mostrar en el mapa">
|
||||||
<div class="footer-text">
|
<div class="footer-text">
|
||||||
<small><ion-icon name="location-outline" style="vertical-align: bottom;"></ion-icon> Tte. Gral. Juan Domingo Perón 802 (C1038AAR)<br />CABA - Argentina</small>
|
<small><ion-icon name="location-outline" style="vertical-align: bottom;"></ion-icon> Tte. Gral. Juan Domingo
|
||||||
|
Perón 802 (C1038AAR)<br />CABA - Argentina</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
|
|||||||
+347
-212
@@ -1,212 +1,347 @@
|
|||||||
.filter-section {
|
.filter-section {
|
||||||
background: var(--ion-background-color);
|
background: var(--ion-background-color);
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
|
|
||||||
ion-item {
|
ion-item {
|
||||||
--background: transparent;
|
--background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-container {
|
.loading-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 50px;
|
margin-top: 50px;
|
||||||
color: var(--ion-color-medium);
|
color: var(--ion-color-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 30px 20px;
|
padding: 30px 20px;
|
||||||
color: var(--ion-color-medium);
|
color: var(--ion-color-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-state {
|
.error-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 30px 20px;
|
padding: 30px 20px;
|
||||||
color: var(--ion-color-danger);
|
color: var(--ion-color-danger);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header styles
|
// Header styles
|
||||||
.header-content {
|
.header-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
|
|
||||||
.header-logo {
|
.header-logo {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
width: auto;
|
width: auto;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer replica styles
|
// Footer replica styles
|
||||||
.footer-content {
|
.footer-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
|
||||||
.footer-text {
|
.footer-text {
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-segment {
|
.view-segment {
|
||||||
background: var(--ion-background-color);
|
background: var(--ion-background-color);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header-flex {
|
.card-header-flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
||||||
.header-titles {
|
.header-titles {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fav-icon {
|
.fav-icon {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.aula-container {
|
.aula-container {
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
|
|
||||||
.aula-tag {
|
.aula-tag {
|
||||||
color: #682cad;
|
color: #682cad;
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ion-card {
|
ion-card {
|
||||||
margin: 8px 10px;
|
margin: 8px 10px;
|
||||||
|
|
||||||
ion-card-header {
|
ion-card-header {
|
||||||
padding: 10px 14px 0;
|
padding: 10px 14px 0;
|
||||||
|
|
||||||
ion-card-title {
|
ion-card-title {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ion-card-content {
|
ion-card-content {
|
||||||
padding: 10px 14px 14px;
|
padding: 10px 14px 14px;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aula-container {
|
.aula-container {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
|
|
||||||
.aula-tag {
|
.aula-tag {
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ion-card.subject-card {
|
ion-card.subject-card {
|
||||||
margin: 8px 12px;
|
margin: 8px 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: transform 0.1s;
|
transition: transform 0.1s;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
||||||
.share-icon,
|
.share-icon,
|
||||||
.fav-icon {
|
.menu-icon {
|
||||||
font-size: 1.5rem;
|
font-size: 1.6rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.15s;
|
transition: transform 0.2s;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(1.2);
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.link-container {
|
.link-container {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
|
||||||
ion-button {
|
ion-button {
|
||||||
--border-radius: 8px;
|
--border-radius: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-text {
|
.schedule-text {
|
||||||
color: #7B2049;
|
color: #7B2049;
|
||||||
/* Secondary Uva color */
|
/* Secondary Uva color */
|
||||||
font-size: 0.98rem !important;
|
font-size: 0.98rem !important;
|
||||||
font-weight: bold !important;
|
font-weight: bold !important;
|
||||||
margin-top: 8px !important;
|
margin-top: 8px !important;
|
||||||
margin-bottom: 8px !important;
|
margin-bottom: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlight-card {
|
.highlight-card {
|
||||||
border: 2px solid var(--ion-color-secondary) !important;
|
border: 2px solid var(--ion-color-secondary) !important;
|
||||||
animation: pulse-border 1s alternate infinite;
|
animation: pulse-border 1s alternate infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-border {
|
@keyframes pulse-border {
|
||||||
from {
|
from {
|
||||||
box-shadow: 0 0 5px var(--ion-color-secondary);
|
box-shadow: 0 0 5px var(--ion-color-secondary);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
box-shadow: 0 0 20px var(--ion-color-secondary);
|
box-shadow: 0 0 20px var(--ion-color-secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode overwrites for custom colored text */
|
/* Dark mode overwrites for custom colored text */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.aula-container .aula-tag {
|
.aula-container .aula-tag {
|
||||||
color: #ae7fff; /* Lighter vivid purple for dark background */
|
color: #ae7fff; /* Lighter vivid purple for dark background */
|
||||||
}
|
}
|
||||||
|
|
||||||
.schedule-text {
|
.schedule-text {
|
||||||
color: #ff8ab6 !important; /* Lighter magenta for dark background */
|
color: #ff8ab6 !important; /* Lighter magenta for dark background */
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlight-card {
|
.highlight-card {
|
||||||
border-color: var(--ion-color-primary) !important;
|
border-color: var(--ion-color-primary) !important;
|
||||||
animation: pulse-border-dark 1s alternate infinite;
|
animation: pulse-border-dark 1s alternate infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-border-dark {
|
@keyframes pulse-border-dark {
|
||||||
from {
|
from {
|
||||||
box-shadow: 0 0 5px var(--ion-color-primary);
|
box-shadow: 0 0 5px var(--ion-color-primary);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
box-shadow: 0 0 20px var(--ion-color-primary);
|
box-shadow: 0 0 20px var(--ion-color-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
.proxima-clase-card {
|
||||||
|
background: linear-gradient(135deg, #1e2a4a, #1a1f3c) !important;
|
||||||
|
border-color: rgba(var(--ion-color-primary-rgb), 0.6) !important;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.proxima-label { color: rgba(255, 255, 255, 0.6); }
|
||||||
|
.proxima-asignatura { color: #fff; }
|
||||||
|
.proxima-detalles { color: rgba(255, 255, 255, 0.75); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-badge {
|
||||||
|
--background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||||
|
color: var(--ion-color-secondary-contrast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Hitos Académicos (Agenda) ──────────────────────────────────────
|
||||||
|
|
||||||
|
.evento-badge-container {
|
||||||
|
margin-top: 5px;
|
||||||
|
padding: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-badge {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Banner: Próxima Clase ───────────────────────────────────────────
|
||||||
|
|
||||||
|
.proxima-clase-container {
|
||||||
|
padding: 8px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxima-clase-card {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1.5px solid rgba(var(--ion-color-primary-rgb), 0.35);
|
||||||
|
background: linear-gradient(135deg, #eef2ff, #f5f0ff);
|
||||||
|
box-shadow: 0 4px 18px rgba(var(--ion-color-primary-rgb), 0.15);
|
||||||
|
transition: border-color 0.3s, box-shadow 0.3s;
|
||||||
|
|
||||||
|
ion-card-content {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.urgente {
|
||||||
|
border-color: rgba(var(--ion-color-danger-rgb), 0.5);
|
||||||
|
background: linear-gradient(135deg, #fff0f3, #ffe8ed);
|
||||||
|
box-shadow: 0 4px 18px rgba(var(--ion-color-danger-rgb), 0.18);
|
||||||
|
animation: pulse-urgente 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-urgente {
|
||||||
|
0%, 100% { box-shadow: 0 4px 18px rgba(var(--ion-color-danger-rgb), 0.15); }
|
||||||
|
50% { box-shadow: 0 4px 28px rgba(var(--ion-color-danger-rgb), 0.35); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxima-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.proxima-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
|
||||||
|
ion-icon { font-size: 1rem; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-chip {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
height: 28px;
|
||||||
|
--padding-start: 10px;
|
||||||
|
--padding-end: 10px;
|
||||||
|
|
||||||
|
ion-icon { margin-right: 4px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-chip {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.75; transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-chip {
|
||||||
|
animation: pulse-chip 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxima-asignatura {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxima-detalles {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--ion-color-medium-shade);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
ion-icon { font-size: 1rem; flex-shrink: 0; }
|
||||||
|
}
|
||||||
|
}␍
|
||||||
+301
-20
@@ -1,6 +1,7 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { ToastController, ModalController, AlertController } from '@ionic/angular';
|
import { ToastController, ModalController, AlertController, ActionSheetController, IonicSafeString } from '@ionic/angular';
|
||||||
import { DataService, ClaseHorario } from '../services/data';
|
|
||||||
|
import { DataService, ClaseHorario, EventoAcademico } from '../services/data';
|
||||||
import { Preferences } from '@capacitor/preferences';
|
import { Preferences } from '@capacitor/preferences';
|
||||||
import { Filesystem } from '@capacitor/filesystem';
|
import { Filesystem } from '@capacitor/filesystem';
|
||||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||||
@@ -9,6 +10,17 @@ import { Share } from '@capacitor/share';
|
|||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { CredencialModalComponent } from '../components/credencial-modal/credencial-modal.component';
|
import { CredencialModalComponent } from '../components/credencial-modal/credencial-modal.component';
|
||||||
import { CsvStorageService } from '../services/csv-storage.service';
|
import { CsvStorageService } from '../services/csv-storage.service';
|
||||||
|
import { EventosService } from '../services/eventos.service';
|
||||||
|
import { EventoModalComponent } from '../components/evento-modal/evento-modal.component';
|
||||||
|
import { AgendaModalComponent } from '../components/agenda-modal/agenda-modal.component';
|
||||||
|
|
||||||
|
|
||||||
|
export interface ProximaClaseInfo {
|
||||||
|
item: ClaseHorario;
|
||||||
|
diffMs: number;
|
||||||
|
label: 'Próxima Clase' | 'Clase en Curso';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
@@ -30,15 +42,30 @@ export class HomePage implements OnInit {
|
|||||||
errorMsg: string = '';
|
errorMsg: string = '';
|
||||||
isInfoModalOpen: boolean = false;
|
isInfoModalOpen: boolean = false;
|
||||||
|
|
||||||
|
proximaClase: ProximaClaseInfo | null = null;
|
||||||
|
private countdownTimer: any = null;
|
||||||
|
|
||||||
|
eventos: EventoAcademico[] = [];
|
||||||
|
|
||||||
|
|
||||||
|
// Mapa de días en español al getDay() de JS (0=Dom)
|
||||||
|
private readonly DIAS_DOW: Record<string, number> = {
|
||||||
|
'Lunes': 1, 'Martes': 2, 'Miércoles': 3, 'Miercoles': 3,
|
||||||
|
'Jueves': 4, 'Viernes': 5, 'Sábado': 6, 'Sabado': 6, 'Domingo': 0
|
||||||
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private toastCtrl: ToastController,
|
private toastCtrl: ToastController,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private modalCtrl: ModalController,
|
private modalCtrl: ModalController,
|
||||||
private alertCtrl: AlertController,
|
private alertCtrl: AlertController,
|
||||||
private csvStorageService: CsvStorageService
|
private csvStorageService: CsvStorageService,
|
||||||
|
private actionSheetCtrl: ActionSheetController,
|
||||||
|
private eventosService: EventosService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.route.queryParams.subscribe(params => {
|
this.route.queryParams.subscribe(params => {
|
||||||
this.highlightedCodigo = params['highlight'] || null;
|
this.highlightedCodigo = params['highlight'] || null;
|
||||||
@@ -65,25 +92,31 @@ export class HomePage implements OnInit {
|
|||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
// 0. Intentar cargar los favoritos desde el archivo CSV seguro (sobrevive desinstalaciones)
|
// 0. Intentar cargar los datos de usuario desde el archivo CSV seguro
|
||||||
const csvFavs = await this.csvStorageService.loadFavoritesFromCsv();
|
const userData = await this.csvStorageService.loadUserDataFromCsv();
|
||||||
if (csvFavs && csvFavs.length > 0) {
|
if (userData) {
|
||||||
this.favoritos = csvFavs;
|
this.favoritos = userData.favoritos;
|
||||||
|
// Sincronizar eventos cargados desde el CSV con el servicio de eventos
|
||||||
|
for (const ev of userData.eventos) {
|
||||||
|
await this.eventosService.saveEvento(ev);
|
||||||
|
}
|
||||||
|
this.eventos = await this.eventosService.loadEventos();
|
||||||
|
|
||||||
this.viewMode = 'favoritos';
|
this.viewMode = 'favoritos';
|
||||||
// Restaurar también la preferencia rápida nativa para mantener sincronía
|
|
||||||
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
|
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
|
||||||
} else {
|
} else {
|
||||||
// 1. Fallback: cargar los favoritos de las preferencias de Capacitor
|
// 1. Fallback: cargar los favoritos de las preferencias de Capacitor si no hay CSV
|
||||||
const favPref = await Preferences.get({ key: 'favoritos' });
|
const favPref = await Preferences.get({ key: 'favoritos' });
|
||||||
if (favPref.value && JSON.parse(favPref.value).length > 0) {
|
if (favPref.value && JSON.parse(favPref.value).length > 0) {
|
||||||
this.favoritos = JSON.parse(favPref.value);
|
this.favoritos = JSON.parse(favPref.value);
|
||||||
this.viewMode = 'favoritos';
|
this.viewMode = 'favoritos';
|
||||||
} else {
|
} else {
|
||||||
// Si es una instalación totalmente fresca (sin config ni CSV auto-encontrado)
|
|
||||||
await this.promptForRestore();
|
await this.promptForRestore();
|
||||||
}
|
}
|
||||||
|
this.eventos = await this.eventosService.loadEventos();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 2. Load from Cache first (Offline mode)
|
// 2. Load from Cache first (Offline mode)
|
||||||
const cached = await this.dataService.getCachedHorarios();
|
const cached = await this.dataService.getCachedHorarios();
|
||||||
if (cached.length > 0) {
|
if (cached.length > 0) {
|
||||||
@@ -114,8 +147,16 @@ export class HomePage implements OnInit {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.errorMsg = '';
|
this.errorMsg = '';
|
||||||
|
|
||||||
|
// Programar recordatorios de clase 30 min antes
|
||||||
|
const favItems = this.horarios.filter(h => this.favoritos.includes(h.codigo));
|
||||||
|
this.dataService.scheduleClassReminders(favItems);
|
||||||
|
|
||||||
|
// Auto-reparación/Actualización del CSV de respaldo
|
||||||
|
this.csvStorageService.saveUserDataToCsv(this.favoritos, this.eventos);
|
||||||
|
|
||||||
if (event) event.target.complete();
|
if (event) event.target.complete();
|
||||||
},
|
},
|
||||||
|
|
||||||
error: async (err) => {
|
error: async (err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
if (this.horarios.length === 0) {
|
if (this.horarios.length === 0) {
|
||||||
@@ -156,7 +197,7 @@ export class HomePage implements OnInit {
|
|||||||
this.viewMode = 'favoritos';
|
this.viewMode = 'favoritos';
|
||||||
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
|
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
|
||||||
this.filterHorarios();
|
this.filterHorarios();
|
||||||
|
|
||||||
const toast = await this.toastCtrl.create({ message: 'Respaldo restaurado con éxito.', duration: 2500, color: 'success' });
|
const toast = await this.toastCtrl.create({ message: 'Respaldo restaurado con éxito.', duration: 2500, color: 'success' });
|
||||||
toast.present();
|
toast.present();
|
||||||
} else {
|
} else {
|
||||||
@@ -174,18 +215,16 @@ export class HomePage implements OnInit {
|
|||||||
let baseList = this.horarios;
|
let baseList = this.horarios;
|
||||||
|
|
||||||
if (this.viewMode === 'favoritos') {
|
if (this.viewMode === 'favoritos') {
|
||||||
// En modo favoritos, ignoramos el buscador de la otra pestaña
|
|
||||||
baseList = baseList.filter(h => this.favoritos.includes(h.codigo));
|
baseList = baseList.filter(h => this.favoritos.includes(h.codigo));
|
||||||
|
this.actualizarProximaClase();
|
||||||
} else {
|
} else {
|
||||||
// En modo todas, aplicamos filtro de búsqueda con lógica Fuzzy y Carrera
|
this.proximaClase = null;
|
||||||
if (this.searchText && this.searchText.trim() !== '') {
|
if (this.searchText && this.searchText.trim() !== '') {
|
||||||
const normalizedSearch = this.normalizeText(this.searchText);
|
const normalizedSearch = this.normalizeText(this.searchText);
|
||||||
const searchWords = normalizedSearch.split(/\s+/).filter(w => w.length > 0);
|
const searchWords = normalizedSearch.split(/\s+/).filter(w => w.length > 0);
|
||||||
|
|
||||||
baseList = baseList.filter(h => {
|
baseList = baseList.filter(h => {
|
||||||
const contentToSearch = this.normalizeText(`${h.asignatura} ${h.codigo} ${h.aula} ${h.carrera} ${h.horario}`);
|
const contentToSearch = this.normalizeText(`${h.asignatura} ${h.codigo} ${h.aula} ${h.carrera} ${h.horario}`);
|
||||||
|
|
||||||
// Cada palabra buscada debe tener al menos un "fuzzy match" en el contenido
|
|
||||||
return searchWords.every(word => this.fuzzyMatch(contentToSearch, word));
|
return searchWords.every(word => this.fuzzyMatch(contentToSearch, word));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -194,6 +233,111 @@ export class HomePage implements OnInit {
|
|||||||
this.horariosFiltrados = baseList;
|
this.horariosFiltrados = baseList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private actualizarProximaClase() {
|
||||||
|
if (!this.horarios || this.favoritos.length === 0) {
|
||||||
|
this.proximaClase = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ahora = new Date();
|
||||||
|
const hoyIdx = ahora.getDay(); // 0-6
|
||||||
|
const horaActualStr = ahora.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
|
|
||||||
|
let candidata: ProximaClaseInfo | null = null;
|
||||||
|
|
||||||
|
// 1. Buscar si hay alguna clase en curso AHORA
|
||||||
|
const favsHoy = this.horarios.filter(h => this.isFavorito(h.codigo) && this.DIAS_DOW[h.dia || ''] === hoyIdx);
|
||||||
|
|
||||||
|
for (const item of favsHoy) {
|
||||||
|
const parts = (item.horario || '').split('-');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const mInicio = parts[0].match(/(\d{1,2}):(\d{2})/);
|
||||||
|
const mFin = parts[1].match(/(\d{1,2}):(\d{2})/);
|
||||||
|
if (mInicio && mFin) {
|
||||||
|
const tInicio = `${mInicio[1].padStart(2, '0')}:${mInicio[2]}`;
|
||||||
|
const tFin = `${mFin[1].padStart(2, '0')}:${mFin[2]}`;
|
||||||
|
if (horaActualStr >= tInicio && horaActualStr <= tFin) {
|
||||||
|
this.proximaClase = { item, diffMs: 0, label: 'Clase en Curso' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Si no hay en curso, buscar la PRÓXIMA (hoy más tarde o mañana)
|
||||||
|
for (const item of favsHoy) {
|
||||||
|
const mInicio = (item.horario || '').match(/(\d{1,2}):(\d{2})/);
|
||||||
|
if (mInicio) {
|
||||||
|
const tInicio = `${mInicio[1].padStart(2, '0')}:${mInicio[2]}`;
|
||||||
|
if (tInicio > horaActualStr) {
|
||||||
|
const targetDate = new Date(ahora);
|
||||||
|
targetDate.setHours(parseInt(mInicio[1], 10), parseInt(mInicio[2], 10), 0, 0);
|
||||||
|
|
||||||
|
const diff = targetDate.getTime() - ahora.getTime();
|
||||||
|
if (!candidata || diff < candidata.diffMs) {
|
||||||
|
candidata = { item, diffMs: diff, label: 'Próxima Clase' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no encontramos hoy, buscamos mañana
|
||||||
|
if (!candidata) {
|
||||||
|
const mananaIdx = (hoyIdx + 1) % 7;
|
||||||
|
const favsManana = this.horarios.filter(h => this.isFavorito(h.codigo) && this.DIAS_DOW[h.dia || ''] === mananaIdx);
|
||||||
|
|
||||||
|
for (const item of favsManana) {
|
||||||
|
const mInicio = (item.horario || '').match(/(\d{1,2}):(\d{2})/);
|
||||||
|
if (mInicio) {
|
||||||
|
const targetDate = new Date(ahora);
|
||||||
|
targetDate.setDate(ahora.getDate() + 1);
|
||||||
|
targetDate.setHours(parseInt(mInicio[1], 10), parseInt(mInicio[2], 10), 0, 0);
|
||||||
|
|
||||||
|
const diff = targetDate.getTime() - ahora.getTime();
|
||||||
|
if (!candidata || diff < candidata.diffMs) {
|
||||||
|
candidata = { item, diffMs: diff, label: 'Próxima Clase' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
this.proximaClase = candidata;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reiniciarCountdown() {
|
||||||
|
if (this.countdownTimer) {
|
||||||
|
clearInterval(this.countdownTimer);
|
||||||
|
this.countdownTimer = null;
|
||||||
|
}
|
||||||
|
if (this.proximaClase) {
|
||||||
|
this.countdownTimer = setInterval(() => {
|
||||||
|
if (!this.proximaClase) { clearInterval(this.countdownTimer); return; }
|
||||||
|
this.proximaClase.diffMs -= 60000; // Decrement by 1 minute
|
||||||
|
if (this.proximaClase.diffMs < -5 * 60000) { // If 5 minutes past start
|
||||||
|
// The class has started or passed, recalculate
|
||||||
|
this.actualizarProximaClase();
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatCountdown(diffMs: number): string {
|
||||||
|
const minutos = Math.round(diffMs / 60000);
|
||||||
|
if (minutos <= 0) return 'Ahora mismo';
|
||||||
|
if (minutos < 60) return `En ${minutos} min`;
|
||||||
|
const horas = Math.floor(minutos / 60);
|
||||||
|
const mins = minutos % 60;
|
||||||
|
return mins > 0 ? `En ${horas}h ${mins}min` : `En ${horas}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
countdownColor(diffMs: number): string {
|
||||||
|
const minutos = Math.round(diffMs / 60000);
|
||||||
|
if (minutos <= 30) return 'danger';
|
||||||
|
if (minutos <= 120) return 'warning';
|
||||||
|
return 'primary';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determina si hay una coincidencia aproximada (Fuzzy)
|
* Determina si hay una coincidencia aproximada (Fuzzy)
|
||||||
*/
|
*/
|
||||||
@@ -274,8 +418,8 @@ export class HomePage implements OnInit {
|
|||||||
|
|
||||||
const alert = await this.alertCtrl.create({
|
const alert = await this.alertCtrl.create({
|
||||||
header: isFav ? 'Quitar de Favoritos' : 'Agregar a Favoritos',
|
header: isFav ? 'Quitar de Favoritos' : 'Agregar a Favoritos',
|
||||||
message: isFav
|
message: isFav
|
||||||
? `¿Deseas quitar la materia ${item.asignatura} de tus favoritos?`
|
? `¿Deseas quitar la materia ${item.asignatura} de tus favoritos?`
|
||||||
: `¿Deseas agregar la materia ${item.asignatura} a tus favoritos?`,
|
: `¿Deseas agregar la materia ${item.asignatura} a tus favoritos?`,
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
@@ -300,13 +444,14 @@ export class HomePage implements OnInit {
|
|||||||
this.favoritos.push(item.codigo);
|
this.favoritos.push(item.codigo);
|
||||||
}
|
}
|
||||||
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
|
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
|
||||||
await this.csvStorageService.saveFavoritesToCsv(this.favoritos);
|
await this.csvStorageService.saveUserDataToCsv(this.favoritos, this.eventos);
|
||||||
this.filterHorarios();
|
this.filterHorarios();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
await alert.present();
|
await alert.present();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +496,143 @@ export class HomePage implements OnInit {
|
|||||||
const modal = await this.modalCtrl.create({
|
const modal = await this.modalCtrl.create({
|
||||||
component: CredencialModalComponent
|
component: CredencialModalComponent
|
||||||
});
|
});
|
||||||
|
|
||||||
await modal.present();
|
await modal.present();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Hitos Académicos (Agenda) ---
|
||||||
|
|
||||||
|
async openMateriaActions(item: ClaseHorario) {
|
||||||
|
// Si estamos en cartelera general y NO es favorito, lo agregamos directamente
|
||||||
|
if (this.viewMode === 'todas' && !this.isFavorito(item.codigo)) {
|
||||||
|
this.toggleFavorito(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// En cualquier otro caso (estamos en favoritos o ya es favorito en la cartelera),
|
||||||
|
// abrimos el menú de gestión (Action Sheet)
|
||||||
|
const proximos = this.getEventosMateria(item.codigo);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const buttons: any[] = [
|
||||||
|
{
|
||||||
|
text: 'Agendar Parcial / TP / Final',
|
||||||
|
icon: 'calendar-number-outline',
|
||||||
|
handler: () => {
|
||||||
|
this.abrirModalEvento(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Compartir Horario',
|
||||||
|
icon: 'share-social-outline',
|
||||||
|
handler: () => {
|
||||||
|
this.shareItem(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (proximos.length > 0) {
|
||||||
|
buttons.unshift({
|
||||||
|
text: 'Ver Agenda de la Materia',
|
||||||
|
icon: 'list-outline',
|
||||||
|
handler: () => {
|
||||||
|
this.verAgendaMateria(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons.push({
|
||||||
|
text: 'Quitar de Favoritos',
|
||||||
|
role: 'destructive',
|
||||||
|
icon: 'star-outline',
|
||||||
|
handler: () => {
|
||||||
|
this.toggleFavorito(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
buttons.push({
|
||||||
|
text: 'Cancelar',
|
||||||
|
role: 'cancel',
|
||||||
|
icon: 'close-outline'
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionSheet = await this.actionSheetCtrl.create({
|
||||||
|
header: item.asignatura,
|
||||||
|
subHeader: 'Gestión de materia',
|
||||||
|
buttons: buttons
|
||||||
|
});
|
||||||
|
|
||||||
|
await actionSheet.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
getEventosMateria(codigo: string): EventoAcademico[] {
|
||||||
|
return this.eventos
|
||||||
|
.filter(e => e.materiaCodigo === codigo)
|
||||||
|
.sort((a, b) => new Date(a.fecha).getTime() - new Date(b.fecha).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
getProximoEvento(codigo: string): EventoAcademico | null {
|
||||||
|
const ahora = new Date().getTime();
|
||||||
|
const futuros = this.getEventosMateria(codigo)
|
||||||
|
.filter(e => new Date(e.fecha).getTime() > ahora);
|
||||||
|
|
||||||
|
return futuros.length > 0 ? futuros[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async abrirModalEvento(item: ClaseHorario, eventoExistente?: EventoAcademico) {
|
||||||
|
const modal = await this.modalCtrl.create({
|
||||||
|
component: EventoModalComponent,
|
||||||
|
componentProps: {
|
||||||
|
materiaCodigo: item.codigo,
|
||||||
|
materiaNombre: item.asignatura,
|
||||||
|
eventoExistente: eventoExistente
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await modal.present();
|
||||||
|
|
||||||
|
const { data, role } = await modal.onWillDismiss();
|
||||||
|
if (role === 'saved' || role === 'deleted') {
|
||||||
|
this.eventos = await this.eventosService.loadEventos();
|
||||||
|
await this.csvStorageService.saveUserDataToCsv(this.favoritos, this.eventos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verAgendaMateria(item: ClaseHorario) {
|
||||||
|
const modal = await this.modalCtrl.create({
|
||||||
|
component: AgendaModalComponent,
|
||||||
|
componentProps: {
|
||||||
|
materia: item
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await modal.present();
|
||||||
|
|
||||||
|
await modal.onWillDismiss();
|
||||||
|
|
||||||
|
// Al cerrar la agenda, recargamos los eventos locales por si se agregó/editó alguno
|
||||||
|
this.eventos = await this.eventosService.loadEventos();
|
||||||
|
|
||||||
|
// Y aseguramos que la próxima clase y los badges se actualicen
|
||||||
|
this.actualizarProximaClase();
|
||||||
|
|
||||||
|
// Guardamos en CSV por si hubo cambios
|
||||||
|
await this.csvStorageService.saveUserDataToCsv(this.favoritos, this.eventos);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
formatFechaEvento(fechaIso: string): string {
|
||||||
|
const d = new Date(fechaIso);
|
||||||
|
return d.toLocaleDateString([], { day: '2-digit', month: '2-digit' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
isEventoUrgente(fechaIso: string): boolean {
|
||||||
|
const diff = new Date(fechaIso).getTime() - new Date().getTime();
|
||||||
|
return diff > 0 && diff < (1000 * 60 * 60 * 48); // Menos de 48 horas
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
||||||
import { FilePicker } from '@capawesome/capacitor-file-picker';
|
import { FilePicker } from '@capawesome/capacitor-file-picker';
|
||||||
|
import { EventoAcademico } from './data';
|
||||||
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -23,7 +25,7 @@ export class CsvStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveFavoritesToCsv(favoritos: string[]) {
|
async saveUserDataToCsv(favoritos: string[], eventos: EventoAcademico[]) {
|
||||||
try {
|
try {
|
||||||
const hasPermission = await this.ensurePermissions();
|
const hasPermission = await this.ensurePermissions();
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
@@ -31,12 +33,22 @@ export class CsvStorageService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let csvContent = 'tipo,valor\n';
|
let csvContent = 'tipo,materia,id_o_valor,extra1,extra2,extra3,extra4\n';
|
||||||
|
|
||||||
|
// Filas de favoritos
|
||||||
favoritos.forEach(codigo => {
|
favoritos.forEach(codigo => {
|
||||||
csvContent += `favorito,${codigo}\n`;
|
csvContent += `favorito,${codigo},,,,\n`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Intentamos borrar si existe para forzar la actualización transparente en Scoped Storage
|
// Filas de eventos
|
||||||
|
eventos.forEach(ev => {
|
||||||
|
// Formato: evento,CODIGO,ID,TIPO,FECHA,AULA,LINK,NOTAS
|
||||||
|
// Para simplificar el CSV sin escapar comas en notas, reemplazamos comas por punto y coma
|
||||||
|
const notasEscaped = (ev.notas || '').replace(/,/g, ';');
|
||||||
|
csvContent += `evento,${ev.materiaCodigo},${ev.id},${ev.tipo},${ev.fecha},${ev.aula || ''},${ev.link || ''},${notasEscaped}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intentamos borrar si existe
|
||||||
try {
|
try {
|
||||||
await Filesystem.deleteFile({
|
await Filesystem.deleteFile({
|
||||||
path: this.FILE_NAME,
|
path: this.FILE_NAME,
|
||||||
@@ -50,13 +62,14 @@ export class CsvStorageService {
|
|||||||
directory: Directory.Documents,
|
directory: Directory.Documents,
|
||||||
encoding: Encoding.UTF8
|
encoding: Encoding.UTF8
|
||||||
});
|
});
|
||||||
console.log('CSV guardado exitosamente en Documents');
|
console.log('CSV con datos de usuario guardado en Documents');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error al guardar el CSV:', e);
|
console.error('Error al guardar el CSV:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadFavoritesFromCsv(): Promise<string[] | null> {
|
|
||||||
|
async loadUserDataFromCsv(): Promise<{ favoritos: string[], eventos: EventoAcademico[] } | null> {
|
||||||
try {
|
try {
|
||||||
const hasPermission = await this.ensurePermissions();
|
const hasPermission = await this.ensurePermissions();
|
||||||
if (!hasPermission) return null;
|
if (!hasPermission) return null;
|
||||||
@@ -71,23 +84,42 @@ export class CsvStorageService {
|
|||||||
|
|
||||||
const lines = contents.data.toString().split('\n');
|
const lines = contents.data.toString().split('\n');
|
||||||
const favs: string[] = [];
|
const favs: string[] = [];
|
||||||
|
const evs: EventoAcademico[] = [];
|
||||||
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
const line = lines[i].trim();
|
const line = lines[i].trim();
|
||||||
if (line.startsWith('favorito,')) {
|
if (!line) continue;
|
||||||
const parts = line.split(',');
|
|
||||||
|
const parts = line.split(',');
|
||||||
|
const tipo = parts[0];
|
||||||
|
|
||||||
|
if (tipo === 'favorito') {
|
||||||
if (parts.length > 1 && parts[1]) {
|
if (parts.length > 1 && parts[1]) {
|
||||||
favs.push(parts[1]);
|
favs.push(parts[1]);
|
||||||
}
|
}
|
||||||
|
} else if (tipo === 'evento') {
|
||||||
|
// evento,materia,id,tipo_ev,fecha,aula,link,notas
|
||||||
|
if (parts.length >= 5) {
|
||||||
|
evs.push({
|
||||||
|
materiaCodigo: parts[1],
|
||||||
|
id: parts[2],
|
||||||
|
tipo: parts[3] as any,
|
||||||
|
fecha: parts[4],
|
||||||
|
aula: parts[5] || '',
|
||||||
|
link: parts[6] || '',
|
||||||
|
notas: (parts[7] || '').replace(/;/g, ',')
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return favs.length > 0 ? favs : null;
|
return { favoritos: favs, eventos: evs };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async importFavoritesFromFilePicker(): Promise<string[] | null> {
|
async importFavoritesFromFilePicker(): Promise<string[] | null> {
|
||||||
try {
|
try {
|
||||||
const result = await FilePicker.pickFiles({
|
const result = await FilePicker.pickFiles({
|
||||||
@@ -105,18 +137,25 @@ export class CsvStorageService {
|
|||||||
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
const line = lines[i].trim();
|
const line = lines[i].trim();
|
||||||
if (line.startsWith('favorito,')) {
|
if (!line) continue;
|
||||||
const parts = line.split(',');
|
|
||||||
|
const parts = line.split(',');
|
||||||
|
const tipo = parts[0];
|
||||||
|
|
||||||
|
if (tipo === 'favorito') {
|
||||||
if (parts.length > 1 && parts[1]) {
|
if (parts.length > 1 && parts[1]) {
|
||||||
favs.push(parts[1]);
|
favs.push(parts[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Nota: para el Picker manual solo recuperamos favoritos por ahora para simplicidad,
|
||||||
|
// o podríamos reusar la lógica de loadUserDataFromCsv si el CSV es idéntico.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (favs.length > 0) {
|
if (favs.length > 0) {
|
||||||
await this.saveFavoritesToCsv(favs);
|
await this.saveUserDataToCsv(favs, []);
|
||||||
return favs;
|
return favs;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.warn('File picker cancelado o falló', e);
|
console.warn('File picker cancelado o falló', e);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { Observable, forkJoin, from, of } from 'rxjs';
|
import { Observable, forkJoin, from, of } from 'rxjs';
|
||||||
import { map, tap, catchError } from 'rxjs/operators';
|
import { map, tap, catchError } from 'rxjs/operators';
|
||||||
import { Preferences } from '@capacitor/preferences';
|
import { Preferences } from '@capacitor/preferences';
|
||||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
import { LocalNotifications, LocalNotificationSchema } from '@capacitor/local-notifications';
|
||||||
|
|
||||||
export interface ClaseHorario {
|
export interface ClaseHorario {
|
||||||
codigo: string;
|
codigo: string;
|
||||||
@@ -15,6 +15,17 @@ export interface ClaseHorario {
|
|||||||
link?: string;
|
link?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EventoAcademico {
|
||||||
|
id: string;
|
||||||
|
materiaCodigo: string;
|
||||||
|
tipo: 'Parcial' | 'TP' | 'Final' | 'Otro';
|
||||||
|
fecha: string; // ISO string
|
||||||
|
aula?: string;
|
||||||
|
link?: string;
|
||||||
|
notas?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
@@ -96,6 +107,84 @@ export class DataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programa notificaciones locales 30 minutos antes de cada clase favorita del día actual.
|
||||||
|
* Usa IDs estables (hash del código + dia + horario) para evitar notificaciones duplicadas.
|
||||||
|
*/
|
||||||
|
public async scheduleClassReminders(favoriteItems: ClaseHorario[]) {
|
||||||
|
try {
|
||||||
|
const permStatus = await LocalNotifications.checkPermissions();
|
||||||
|
if (permStatus.display !== 'granted') return;
|
||||||
|
|
||||||
|
const DIAS_ES: Record<string, number> = {
|
||||||
|
'lunes': 1, 'martes': 2, 'miércoles': 3, 'miercoles': 3,
|
||||||
|
'jueves': 4, 'viernes': 5, 'sábado': 6, 'sabado': 6, 'domingo': 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const todayDow = now.getDay(); // 0=Dom, 1=Lun...
|
||||||
|
|
||||||
|
const notificationsToSchedule: LocalNotificationSchema[] = [];
|
||||||
|
const idsToCancel: number[] = [];
|
||||||
|
|
||||||
|
for (const item of favoriteItems) {
|
||||||
|
if (!item.dia || !item.horario) continue;
|
||||||
|
|
||||||
|
const diaNorm = item.dia.trim().toLowerCase();
|
||||||
|
const classDow = DIAS_ES[diaNorm];
|
||||||
|
if (classDow === undefined || classDow !== todayDow) continue;
|
||||||
|
|
||||||
|
// Extraer primer horario de inicio, ej: "18:00 a 22:00" → "18:00"
|
||||||
|
const timeMatch = item.horario.match(/(\d{1,2}):(\d{2})/);
|
||||||
|
if (!timeMatch) continue;
|
||||||
|
|
||||||
|
const classHour = parseInt(timeMatch[1], 10);
|
||||||
|
const classMin = parseInt(timeMatch[2], 10);
|
||||||
|
|
||||||
|
const classStart = new Date(now);
|
||||||
|
classStart.setHours(classHour, classMin, 0, 0);
|
||||||
|
|
||||||
|
const reminderTime = new Date(classStart.getTime() - 30 * 60 * 1000);
|
||||||
|
|
||||||
|
// Solo programa si el recordatorio es en el futuro
|
||||||
|
if (reminderTime <= now) continue;
|
||||||
|
|
||||||
|
// ID estable: hash simple basado en codigo+dia+horario
|
||||||
|
const stableId = this.stableHash(`${item.codigo}-${item.dia}-${item.horario}`);
|
||||||
|
|
||||||
|
idsToCancel.push(stableId);
|
||||||
|
notificationsToSchedule.push({
|
||||||
|
id: stableId,
|
||||||
|
title: `🎓 Clase en 30 minutos`,
|
||||||
|
body: `${item.asignatura} — Aula ${item.aula} a las ${timeMatch[0]}`,
|
||||||
|
schedule: { at: reminderTime },
|
||||||
|
extra: { codigo: item.codigo }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idsToCancel.length > 0) {
|
||||||
|
await LocalNotifications.cancel({ notifications: idsToCancel.map(id => ({ id })) }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationsToSchedule.length > 0) {
|
||||||
|
await LocalNotifications.schedule({ notifications: notificationsToSchedule });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error programando recordatorios de clase:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Genera un entero de 32 bits estable a partir de un string (djb2) */
|
||||||
|
private stableHash(str: string): number {
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
|
||||||
|
hash = hash >>> 0; // Mantener como unsigned 32-bit
|
||||||
|
}
|
||||||
|
// LocalNotifications requiere IDs positivos y razonables
|
||||||
|
return (hash % 2_000_000) + 100_000;
|
||||||
|
}
|
||||||
|
|
||||||
private async checkRoomChanges(oldData: ClaseHorario[], newData: ClaseHorario[], favoritos: string[]) {
|
private async checkRoomChanges(oldData: ClaseHorario[], newData: ClaseHorario[], favoritos: string[]) {
|
||||||
for (const item of newData) {
|
for (const item of newData) {
|
||||||
if (favoritos.includes(item.codigo)) {
|
if (favoritos.includes(item.codigo)) {
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Preferences } from '@capacitor/preferences';
|
||||||
|
import { LocalNotifications, LocalNotificationSchema } from '@capacitor/local-notifications';
|
||||||
|
import { Haptics, ImpactStyle } from '@capacitor/haptics';
|
||||||
|
import { EventoAcademico } from './data';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class EventosService {
|
||||||
|
private readonly PREF_EVENTOS = 'eventos_academicos';
|
||||||
|
private eventos: EventoAcademico[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.loadEventos();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadEventos(): Promise<EventoAcademico[]> {
|
||||||
|
const { value } = await Preferences.get({ key: this.PREF_EVENTOS });
|
||||||
|
if (value) {
|
||||||
|
this.eventos = JSON.parse(value);
|
||||||
|
}
|
||||||
|
return this.eventos;
|
||||||
|
}
|
||||||
|
|
||||||
|
getEventosByMateria(codigo: string): EventoAcademico[] {
|
||||||
|
return this.eventos.filter(e => e.materiaCodigo === codigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllEventos(): EventoAcademico[] {
|
||||||
|
return this.eventos;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveEvento(evento: EventoAcademico) {
|
||||||
|
const index = this.eventos.findIndex(e => e.id === evento.id);
|
||||||
|
if (index > -1) {
|
||||||
|
this.eventos[index] = evento;
|
||||||
|
} else {
|
||||||
|
this.eventos.push(evento);
|
||||||
|
}
|
||||||
|
await this.syncStorage();
|
||||||
|
await this.scheduleNotifications(evento);
|
||||||
|
await Haptics.impact({ style: ImpactStyle.Medium }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEvento(id: string) {
|
||||||
|
this.eventos = this.eventos.filter(e => e.id !== id);
|
||||||
|
await this.syncStorage();
|
||||||
|
await this.cancelNotifications(id);
|
||||||
|
await Haptics.impact({ style: ImpactStyle.Light }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncStorage() {
|
||||||
|
await Preferences.set({
|
||||||
|
key: this.PREF_EVENTOS,
|
||||||
|
value: JSON.stringify(this.eventos)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Programa notificaciones escalonadas para un hito académico: 7 días, 1 día y 2 horas antes.
|
||||||
|
*/
|
||||||
|
async scheduleNotifications(evento: EventoAcademico) {
|
||||||
|
const now = new Date();
|
||||||
|
const eventDate = new Date(evento.fecha);
|
||||||
|
|
||||||
|
if (eventDate <= now) return;
|
||||||
|
|
||||||
|
// Cancelamos notificaciones previas de este ID
|
||||||
|
await this.cancelNotifications(evento.id);
|
||||||
|
|
||||||
|
const notifs: LocalNotificationSchema[] = [];
|
||||||
|
const baseId = this.stringToNumericId(evento.id);
|
||||||
|
|
||||||
|
const milestones = [
|
||||||
|
{ label: 'en 7 días', offset: 7 * 24 * 60 * 60 * 1000, suffix: '7d' },
|
||||||
|
{ label: 'mañana', offset: 24 * 60 * 60 * 1000, suffix: '1d' },
|
||||||
|
{ label: 'en 2 horas', offset: 2 * 60 * 60 * 1000, suffix: '2h' }
|
||||||
|
];
|
||||||
|
|
||||||
|
milestones.forEach((m, i) => {
|
||||||
|
const scheduleAt = new Date(eventDate.getTime() - m.offset);
|
||||||
|
if (scheduleAt > now) {
|
||||||
|
notifs.push({
|
||||||
|
id: baseId + i,
|
||||||
|
title: `📌 Recordatorio: ${evento.tipo}`,
|
||||||
|
body: `${evento.materiaCodigo}: ${m.label} es el examen/entrega.`,
|
||||||
|
schedule: { at: scheduleAt },
|
||||||
|
extra: { eventoId: evento.id, codigo: evento.materiaCodigo }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (notifs.length > 0) {
|
||||||
|
await LocalNotifications.schedule({ notifications: notifs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelNotifications(eventoId: string) {
|
||||||
|
const baseId = this.stringToNumericId(eventoId);
|
||||||
|
const ids = [baseId, baseId + 1, baseId + 2];
|
||||||
|
await LocalNotifications.cancel({ notifications: ids.map(id => ({ id })) }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringToNumericId(str: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash % 100000) + 200000;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user