diff --git a/README.md b/README.md index 48edc83..dbbeffc 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,194 @@ -# Aulas UNICABA +# Aulas 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). +[![Version](https://img.shields.io/badge/version-1.2.3-blue.svg)](package.json) +[![Angular](https://img.shields.io/badge/Angular-20.3-red.svg)](https://angular.io/) +[![Ionic](https://img.shields.io/badge/Ionic-v8-blue.svg)](https://ionicframework.com/) +[![Capacitor](https://img.shields.io/badge/Capacitor-v8.2-119EFF.svg)](https://capacitorjs.com/) +[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) + +Un ecosistema móvil híbrido, especializado y **offline-first** desarrollado para la comunidad académica de la **Universidad de la Ciudad (UNICABA)**. Centraliza la cartelera oficial de horarios y aulas, el seguimiento de la carrera académica, la agenda de exámenes, la integración con SIU Guaraní y Campus Virtual Moodle, la Credencial Digital QR y el acceso a beneficios estudiantiles. --- -## ✨ Características +## ✨ Características Principales (v1.2.3) -### 📋 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. - -### ⭐ Favoritos Inteligentes -- Marcá hasta 5 materias como favoritas para tenerlas a mano en su propia pestaña. -- 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+. -- **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. -- Restauración manual de favoritos desde un respaldo CSV (Google Drive, iCloud, Descargas). - -### 🎓 Banner "Próxima Clase" *(Nuevo en v1.1.6)* -- 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). -- **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. - -### 📅 Agenda de Hitos y Exámenes *(Nuevo en v1.1.8)* -- Añade exámenes (Parciales, Finales) y Trabajos Prácticos directamente desde el menú de una materia. -- Los hitos muestran insignias de tiempo (rojo cuando falta poco, secundario el resto del tiempo) en la tarjeta de la materia en Cartelera. -- La selección de fecha se hace mediante un popover ligero e intuitivo. - -### 📍 Integración Nativa con Mapas *(Mejora v1.1.9)* -- El pie de página renovado con un rediseño UI tipo "botón flotante". -- Utiliza esquemas nativos (`geo:0,0?q=` en Android y `maps://` en iOS) que dejan al usuario **elegir la app de navegación preferida** (Waze, Google Maps, Apple Maps) al tocar la sede. - -### 🪪 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. +### 📋 Cartelera de Horarios y Aulas +- **Visualización organizada**: consulta de la cartelera oficial de horarios por día de la semana (Lunes a Sábado). +- **Modo offline-first**: la cartelera se almacena en caché local y se encuentra disponible instantáneamente sin requerir conexión a internet. +- **Búsqueda fuzzy en tiempo real**: motor de búsqueda tolerante a errores ortográficos por nombre de materia, código oficial, número de aula o carrera. +- **Segmentos de visualización**: alternancia rápida entre *Favoritos*, *Cartelera completa* y *Mi Carrera*. +- **Acceso directo a Aulas Virtuales**: botón integrado para ingresar a clases remotas en Zoom o Google Meet. +- **Compartir horario**: exportación nativa de los datos de cursada a WhatsApp, Telegram y otras aplicaciones del sistema (`@capacitor/share`). +- **Sincronización en background**: actualización periódica y desatendida delegada al sistema operativo mediante Background Fetch. --- -## 🛠️ Información Técnica y Frameworks +### ⭐ Favoritos Inteligentes & Respaldo Resiliente +- **Diferenciación precisa de Equipos/Comisiones (Nuevo en v1.2.3)**: al sincronizar con SIU Guaraní o seleccionar manualmente, el sistema identifica y marca **únicamente el equipo o comisión asignado** (ej: `Equipo 1` vs `Equipo 2`), evitando duplicados cuando las materias comparten el mismo código `ASIG`. +- **Persistencia anti-wipes**: los favoritos y datos se respaldan automáticamente en un archivo `aulas_unicaba_datos.csv` ubicado en la carpeta pública "Documentos" del dispositivo, **sobreviviendo a desinstalaciones y actualizaciones** según las políticas *Storage Scoped* de Android 11+. +- **Alertas automáticas de cambio de aula**: detección en segundo plano de modificaciones en aulas o links virtuales con emisión de notificaciones push locales inmediatas. +- **Gestor de respaldo y restauración**: exportación manual y restauración desde archivos CSV almacenados en el dispositivo o la nube (Google Drive, iCloud, Descargas). -| Componente | Tecnología | +--- + +### 🎓 Banner "Próxima Clase" con Cuenta Regresiva +- **Detección inteligente**: identifica automáticamente la materia favorita con cursada más próxima en el tiempo (hoy o mañana) filtrada por tu equipo específico. +- **Chip dinámico de cuenta regresiva** actualizado minuto a minuto: + - 🔵 **Azul**: más de 2 horas para el inicio. + - 🟡 **Amarillo**: entre 30 minutos y 2 horas. + - 🔴 **Rojo con animación de pulso**: menos de 30 minutos. +- Acceso directo al detalle y agenda de la materia al tocar el banner. + +--- + +### 🔔 Recordatorios Pre-Clase Automáticos +- **Notificaciones locales 30 min antes**: cada sincronización de cartelera programa alertas 30 minutos antes del inicio de cada materia favorita del día. +- **Identificadores estables**: previene notificaciones duplicadas en sincronizaciones sucesivas. +- **Operatividad 100% offline**: funciona localmente en el dispositivo sin depender de servidores de notificaciones externos. + +--- + +### 📅 Agenda de Hitos, Exámenes y Google Calendar +- **Registro de fechas clave**: gestión de Parciales (1 y 2), Trabajos Prácticos (TP 1, 2 y 3), Recuperatorios y Finales directamente desde la tarjeta de cada materia. +- **Insignias de proximidad**: badges visuales en la cartelera con código de color según la cercanía del examen o entrega. +- **Integración con Google Calendar**: sincronización nativa directa del hito con la app de calendario del dispositivo mediante un solo toque (`@ebarooni/capacitor-calendar`). + +--- + +### 🗺️ Mi Carrera (Plan de Estudios y Recorrido Interactivo) +- **Grilla cuatrimestral interactiva**: seguimiento visual del progreso de la carrera para: + - *Licenciatura en Tecnologías Digitales* + - *Licenciatura en Ciencia de Datos* + - *Licenciatura en Desarrollo de Videojuegos* +- **Estados de materias**: marcación rápida de materias como **Aprobadas** (fucsia) o **En Curso** (celeste). +- **Materias optativas**: selección dinámica de asignaturas optativas para completar el trayecto académico. +- **Sincronización con SIU Guaraní**: visualización de notas oficiales y estados de cursada reflejados directamente sobre las celdas del plan de estudios. + +--- + +### 🏫 Integración con SIU Guaraní +- **Autenticación directa y segura**: conexión HTTPS oficial (`https://autogestion.udelaciudad.edu.ar/caba`) con certificados SSL válidos emitidos por Sectigo CA, eliminando errores de validación de cadena. +- **Extracción de Cursadas Activas**: lectura directa de las inscripciones del período lectivo y asignaciones estructuradas (comisión, día, horario y aula). +- **Sincronización de historia académica**: importación automática de materias aprobadas, cursadas regulares y calificaciones oficiales. +- **Certificado de Alumno Regular**: solicitud y descarga automatizada del certificado oficial en formato PDF con apertura nativa en el dispositivo (`@capacitor-community/file-opener`). + +--- + +### 🪪 Credencial Digital Universitaria & Perfil Moodle +- **Perfil unificado**: inicio de sesión con el Campus Virtual Moodle para extraer automáticamente el nombre del estudiante y la fotografía oficial de perfil institucional. +- **Código QR 30% más grande y nítido (Nuevo en v1.2.3)**: diseño optimizado para ocupar todo el ancho disponible del celular, con renderizado PDF a escala 3.0 en alta definición para un escaneo instantáneo en molinetes de acceso. +- **Caché permanente**: credencial disponible en todo momento sin requerir conexión a internet. +- **Brillo automático**: ajuste temporal de la pantalla al 100% de brillo al abrir la credencial para facilitar la lectura del código QR (`@capacitor-community/screen-brightness`). + +--- + +### 🎁 Comunidad de Beneficios UdelaCiudad +- **Descuentos exclusivos en comercios y servicios**: + - 🏋️‍♂️ **ON FIT**: 25% de descuento en membresía mensual con acceso a las 20 sedes en todo el AMBA (máquinas Technogym, plan personalizado, clases grupales y lockers). + - ☕ **BERESHIT (Cafetería)**: 10% de descuento en productos (Tte. Gral. Perón 893, CABA). + - 🍫 **Café & Chocolate**: 10% de descuento en cafetería y delicatessen (Bartolomé Mitre 807, CABA). + - 🍝 **Al Buen Tallarín de Pablo**: 10% de descuento en fábrica de pastas artesanales (Tucumán 1596, CABA). + - 📚 **Atlas Comercial (Librería)**: 10% de descuento en productos de librería y papelería (Bartolomé Mitre 839, CABA). + - 🧘 **Fluire Studio (Pilates)**: 10% de descuento abonando 1 mes y 20% abonando 4 meses (Esmeralda 135, piso 4 B, CABA). + - 🚌 **SOY Estudiante (Transporte)**: instructivo para obtener descuentos en pasajes de micros de larga distancia con tu Certificado de Alumno Regular. + +--- + +### 📖 Manual de Usuario y Centro de Ayuda +- **Guía interactiva integrada**: sección accesible desde el encabezado con instrucciones paso a paso para el uso de la cartelera, seguimiento de carrera, agenda y respaldos. + +--- + +### 📍 Integración Nativa con Mapas +- **Acceso a sede**: botón flotante interactivo en el pie de página con la dirección de la sede institucional (Tte. Gral. J.D. Perón 802, CABA). +- **Compatibilidad multiplataforma**: apertura mediante esquemas nativos (`geo:` en Android y `maps://` en iOS) que permiten elegir entre Google Maps, Waze y Apple Maps. + +--- + +## 🛠️ Tecnologías y Arquitectura + +| Componente | Tecnología | Versión | +|---|---|---| +| **Frontend Framework** | [Angular](https://angular.io/) | `20.3.x` | +| **Componentes UI** | [Ionic Framework](https://ionicframework.com/) | `v8.0.x` | +| **Runtime Nativo** | [Capacitor](https://capacitorjs.com/) | `v8.2.0` | +| **Procesamiento PDF** | [PDF.js](https://mozilla.github.io/pdf.js/) | `5.5.x` | +| **Iconografía** | [Ionicons](https://ionic.io/ionicons) | `7.0.x` | + +### Plugins de Capacitor + +| Plugin | Finalidad | |---|---| -| 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 | +| `@capacitor/filesystem` | Lectura y escritura de respaldos CSV en la carpeta pública "Documentos". | +| `@capawesome/capacitor-file-picker` | Selector de archivos para importación manual de respaldos. | +| `@capacitor/local-notifications` | Notificaciones locales de cambios de aula y recordatorios pre-clase. | +| `@capacitor/preferences` | Almacenamiento seguro en clave-valor para credenciales, caché y estados. | +| `@transistorsoft/capacitor-background-fetch` | Tareas en segundo plano para sincronización automática de cartelera. | +| `@capacitor-community/screen-brightness` | Maximización automática del brillo para escaneo del QR. | +| `@capacitor-community/file-opener` | Apertura nativa del Certificado de Alumno Regular en PDF. | +| `@ebarooni/capacitor-calendar` | Exportación e inserción de hitos y exámenes en el calendario nativo. | +| `@capacitor/haptics` | Respuesta háptica en interacciones táctiles clave. | +| `@capacitor/share` | Diálogo nativo para compartir horarios por mensajería. | +| `@capacitor/network` | Monitorización del estado de conectividad a internet. | +| `@capacitor/clipboard` | Utilidades de portapapeles. | --- -## 📱 Compatibilidad Multiplataforma +## 🔒 Privacidad Zero-Trust (Cero Intermediarios) -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. +1. **Conexiones directas punto a punto**: todas las solicitudes HTTP (Google Sheets, Campus Moodle y SIU Guaraní) son emitidas directamente desde el dispositivo del usuario mediante `CapacitorHttp`, resolviendo restricciones CORS sin recurrir a servidores proxy intermedios. +2. **Cero telemetría**: la aplicación no utiliza Google Analytics, SDKs de publicidad ni herramientas de rastreo. +3. **Almacenamiento local seguro**: las credenciales y datos personales permanecen exclusivamente en el almacenamiento interno cifrado del teléfono del usuario. -### 🤖 Despliegue en Android +--- -**Requisito previo**: [Android Studio](https://developer.android.com/studio) instalado, Node.js y NPM. +## 📱 Compilación y Despliegue + +### Requisitos Previos +- **Node.js**: v18 o superior. +- **NPM**: v9 o superior. +- **Android**: [Android Studio](https://developer.android.com/studio) con SDK 34+ y JDK 17+. +- **iOS**: macOS con [Xcode 15+](https://developer.apple.com/xcode/) y CocoaPods. + +### 🤖 Android ```bash # 1. Instalar dependencias npm install -# 2. Compilación del bundle web +# 2. Compilar bundle web npm run build -# 3. Sincronización con puentes nativos +# 3. Sincronizar puente de Capacitor npx cap sync android -# 4. Abrir en Android Studio para firmar APK o ejecutar por ADB +# 4. Abrir en Android Studio para generar APK/AAB o ejecutar en dispositivo npx cap open android ``` -### 🍏 Despliegue en iOS (iPhone / iPad) - -**Requisito previo**: macOS reciente, [Xcode](https://developer.apple.com/xcode/), Node.js y CocoaPods (`sudo gem install cocoapods`). +### 🍏 iOS ```bash -# 1-2. Instalación y compilación web +# 1. Instalar dependencias y compilar npm install npm run build -# 3. Agregar plataforma iOS (solo la primera vez) +# 2. Agregar plataforma iOS (solo la primera vez) npx cap add ios -# 4. Sincronizar plugins y actualizar Pods +# 3. Sincronizar plugins y actualizar Pods npx cap sync ios -# 5. Abrir en Xcode +# 4. Abrir en Xcode npx cap open ios ``` -> **Nota para Xcode:** Habilitar permisos explícitos para **Local Notifications** y **Background Fetch** en la pestaña *Signing & Capabilities* de Xcode. +> **Nota para iOS**: Asegurarse de habilitar las capacidades de **Background Modes** (Background Fetch) y **Local Notifications** en la pestaña *Signing & Capabilities* de Xcode. --- -## 🔒 Privacidad y Flujo Cero Intermediarios (Zero-Trust) +## 📄 Licencia + +Este proyecto se encuentra bajo la licencia **MIT**. Desarrollado con fines educativos y de soporte a la comunidad estudiantil de la Universidad de la Ciudad. -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"). diff --git a/android/app/build.gradle b/android/app/build.gradle index 7406bea..2df177b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "ar.edu.unicaba.cartelera" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 15 - versionName "1.2.2" + versionCode 16 + versionName "1.2.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/package.json b/package.json index ae8aad9..02157fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aulas-unicaba", - "version": "1.2.2", + "version": "1.2.3", "author": "Ionic Framework", "homepage": "https://ionicframework.com/", "scripts": { diff --git a/privacidad-aulas-unicaba.md b/privacidad-aulas-unicaba.md index 66d4598..10138e3 100644 --- a/privacidad-aulas-unicaba.md +++ b/privacidad-aulas-unicaba.md @@ -27,4 +27,4 @@ La aplicación ha sido diseñada bajo una arquitectura de "cliente liviano" con Esta innovación tecnológica es de carácter informativo. La precisión estricta de asignación de las aulas depende originariamente de la pre-carga impuesta por el cuerpo no-docente en el documento de Spreadsheets. En escenarios contradictorios, la palabra oficial del ala administrativa universitaria primará sobre la respuesta sincronizada en Aulas UNICABA. --- -*Última actualización: 27 de Marzo, 2026 - v1.1.9* +*Última actualización: 28 de Agosto, 2026 - v1.2.3* diff --git a/src/app/components/credencial-modal/credencial-modal.component.html b/src/app/components/credencial-modal/credencial-modal.component.html index b0ba1cb..1ba6576 100644 --- a/src/app/components/credencial-modal/credencial-modal.component.html +++ b/src/app/components/credencial-modal/credencial-modal.component.html @@ -7,7 +7,7 @@ - +
@@ -54,7 +54,9 @@
- Credencial UNICABA +
+ Credencial UNICABA +
diff --git a/src/app/components/credencial-modal/credencial-modal.component.scss b/src/app/components/credencial-modal/credencial-modal.component.scss index 7819a0c..351c797 100644 --- a/src/app/components/credencial-modal/credencial-modal.component.scss +++ b/src/app/components/credencial-modal/credencial-modal.component.scss @@ -4,7 +4,7 @@ justify-content: center; align-items: center; min-height: 80vh; - padding: 20px; + padding: 20px 16px; } .loading-text { @@ -31,7 +31,7 @@ .login-card { background: var(--ion-card-background, #ffffff); color: var(--ion-text-color, #000000); - padding: 30px; + padding: 30px 20px; border-radius: 15px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); width: 100%; @@ -57,23 +57,37 @@ .credential-container { justify-content: flex-start; - padding-top: 40px; + min-height: auto; + padding: 8px 8px 24px 8px; + width: 100%; + + .credential-wrapper { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + } .credential-img { width: 100%; - max-width: 400px; - border-radius: 12px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); + max-width: 450px; + border-radius: 14px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12); border: 1px solid #efefef; - margin-bottom: 30px; + margin-bottom: 18px; + display: block; + object-fit: contain; } .credential-actions { width: 100%; - max-width: 400px; + max-width: 450px; + padding: 0 4px; ion-button { - margin-top: 10px; + margin-top: 6px; + font-weight: 600; + letter-spacing: 0.5px; } } } diff --git a/src/app/components/info-modal/info-modal.component.html b/src/app/components/info-modal/info-modal.component.html index 6a2c8e8..3d51924 100644 --- a/src/app/components/info-modal/info-modal.component.html +++ b/src/app/components/info-modal/info-modal.component.html @@ -94,7 +94,7 @@

No usamos Google Analytics ni SDKs de seguimiento. No hay publicidad. Cero telemetría invasiva.

@@ -103,51 +103,223 @@
-

Guía de Beneficios

-

Aprovechá tu formación en UNICABA

+

Comunidad de Beneficios

+

Descuentos exclusivos para la Comunidad UdelaCiudad

-
-
-
- -

1. SOY Estudiante (Transporte)

-
-

Obtené descuentos en pasajes de micro a todo el país a través del portal de Central de Pasajes.

-
-

¿Cómo activarlo?

-
    -
  1. Entra a Perfil y generá tu Certificado de Alumno Regular.
  2. -
  3. Ingresá a soyestudiante.com.ar.
  4. -
  5. Subí el PDF que guardaste en tu teléfono.
  6. -
  7. ¡Listo! Tu DNI quedará habilitado para descuentos automáticos.
  8. -
-
-
+
-
-
- -

2. Google Students (IA y Nube)

+ +
- - + + +
+
+
+
+ +
+
+

BERESHIT

+

Cafetería

+
+
+ 10% OFF +
+

10% de descuento en productos (excluye promociones vigentes).

+
+
+ + Tte. Gral. Perón 893, CABA (a metros de la sede) +
+
+ + Lun a Vie de 8 a 20 h • Sábados de 8 a 16 h +
+
+
+ + +
+
+
+
+ +
+
+

Café & Chocolate

+

Cafetería & Delicatessen

+
+
+ 10% OFF +
+

10% de descuento en productos (excluye promociones vigentes).

+
+
+ + Bartolomé Mitre 807, CABA +
+
+ + Lun a Vie de 8 a 19 h • Sábados de 8 a 14 h +
+
+
+ + +
+
+
+
+ +
+
+

Al Buen Tallarín de Pablo

+

Fábrica de Pastas

+
+
+ 10% OFF +
+

10% de descuento en productos (excluye promociones vigentes).

+
+
+ + Tucumán 1596, CABA +
+
+ + Mar a Vie de 8 a 19 h • Sáb y Dom de 9:30 a 14:30 h +
+
+
+ + +
+
+
+
+ +
+
+

Atlas Comercial

+

Librería & Papelería

+
+
+ 10% OFF +
+

10% de descuento en productos (excluye promociones vigentes).

+
+
+ + Bartolomé Mitre 839, CABA +
+
+ + Lun a Vie de 9 a 18 h +
+
+
+ + +
+
+
+
+ +
+
+

Fluire Studio

+

Pilates & Salud

+
+
+ Hasta 20% OFF +
+

+ 10% de descuento abonando 1 mes de servicio y 20% de descuento abonando 4 meses de servicio. +

+
+
+ + Esmeralda 135, piso 4 B, CABA +
+
+ + Contacto: fluire.studio.ba@gmail.com +
+
+ + + Contactar por Email + +
+ + +
+
+
+
+ +
+
+

SOY Estudiante

+

Transporte de Larga Distancia

+
+
+ Descuento Nacional +
+

+ Obtené descuentos en pasajes de micro a todo el país a través del portal de Central de Pasajes. +

+
+
+ + Entrá a Perfil, generá tu Certificado de Alumno Regular y subilo a soyestudiante.com.ar. +
+
+
+ +
+ +
diff --git a/src/app/components/info-modal/info-modal.component.scss b/src/app/components/info-modal/info-modal.component.scss index dce64e5..caa7386 100644 --- a/src/app/components/info-modal/info-modal.component.scss +++ b/src/app/components/info-modal/info-modal.component.scss @@ -76,6 +76,153 @@ ion-segment { } } +/* --- ESTILOS DE BENEFICIOS --- */ +.benefits-container { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 25px; +} + +.benefit-card { + background: white; + border-radius: 18px; + padding: 18px; + box-shadow: 0 4px 15px rgba(0,0,0,0.06); + border: 1px solid rgba(0,0,0,0.04); + transition: transform 0.2s ease, box-shadow 0.2s ease; + + &.featured { + border: 1.5px solid var(--ion-color-primary); + background: linear-gradient(180deg, #ffffff 0%, rgba(var(--ion-color-primary-rgb), 0.02) 100%); + box-shadow: 0 6px 20px rgba(var(--ion-color-primary-rgb), 0.12); + } + + .card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; + + .brand-info { + display: flex; + align-items: center; + gap: 12px; + + .brand-icon { + width: 44px; + height: 44px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(var(--ion-color-primary-rgb), 0.1); + color: var(--ion-color-primary); + font-size: 24px; + flex-shrink: 0; + + &.cafe { background: #fff3e0; color: #e65100; } + &.gym { background: #e8f5e9; color: #2e7d32; } + &.pilates { background: #ede7f6; color: #512da8; } + &.pasta { background: #fbe9e7; color: #d84315; } + &.libreria { background: #e1f5fe; color: #0288d1; } + &.travel { background: #e3f2fd; color: #1565c0; } + } + + .title-wrap { + h3 { + margin: 0; + font-size: 1.05rem; + font-weight: 800; + color: var(--ion-color-dark); + } + .category { + margin: 2px 0 0; + font-size: 0.8rem; + color: var(--ion-color-medium); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + } + } + + .discount-badge { + font-size: 0.78rem; + font-weight: 800; + padding: 5px 10px; + border-radius: 20px; + letter-spacing: 0.3px; + } + } + + .benefit-desc { + font-size: 0.92rem; + color: var(--ion-color-dark-shade); + margin: 0 0 12px; + line-height: 1.45; + } + + .meta-grid { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 10px; + border-top: 1px dashed #e0e0e0; + margin-bottom: 12px; + + .meta-row { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 0.84rem; + color: var(--ion-color-medium-shade); + line-height: 1.4; + + ion-icon { + font-size: 16px; + margin-top: 2px; + color: var(--ion-color-primary); + flex-shrink: 0; + } + } + } + + .inclusion-box { + background: #f8fafc; + border-radius: 12px; + padding: 12px 14px; + margin: 10px 0; + border-left: 3px solid var(--ion-color-success); + + h4 { + margin: 0 0 6px; + font-size: 0.85rem; + font-weight: 700; + color: var(--ion-color-dark); + } + + ul { + margin: 0; + padding-left: 18px; + li { + font-size: 0.82rem; + margin-bottom: 4px; + color: #475569; + &:last-child { margin-bottom: 0; } + } + } + } + + .action-btn { + --border-radius: 12px; + font-weight: 700; + font-size: 0.88rem; + margin-top: 6px; + letter-spacing: 0.3px; + } +} + .footer-info { margin-top: 30px; opacity: 0.6; diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index aab2b82..dd06ae9 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -103,8 +103,8 @@
diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 77e3455..226a800 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -108,7 +108,7 @@ export class HomePage implements OnInit { this.highlightedCodigo = params['highlight'] || null; if (this.highlightedCodigo) { // Aseguramos que el highlight sea visible (si no es favorito, ir a 'todas') - if (this.viewMode === 'favoritos' && !this.favoritos.includes(this.highlightedCodigo)) { + if (this.viewMode === 'favoritos' && !this.isFavorito(this.highlightedCodigo)) { this.viewMode = 'todas'; } } @@ -223,7 +223,7 @@ export class HomePage implements OnInit { this.errorMsg = ''; // Programar recordatorios de clase 30 min antes - const favItems = this.horarios.filter(h => this.favoritos.includes(h.codigo)); + const favItems = this.horarios.filter(h => this.isFavorito(h)); this.dataService.scheduleClassReminders(favItems); // Auto-reparación/Actualización del Respaldo @@ -262,7 +262,7 @@ export class HomePage implements OnInit { let baseList = this.horarios; if (this.viewMode === 'favoritos') { - baseList = baseList.filter(h => this.favoritos.includes(h.codigo)); + baseList = baseList.filter(h => this.isFavorito(h)); this.actualizarProximaClase(); } else { this.proximaClase = null; @@ -293,7 +293,7 @@ export class HomePage implements OnInit { 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); + const favsHoy = this.horarios.filter(h => this.isFavorito(h) && this.DIAS_DOW[h.dia || ''] === hoyIdx); for (const item of favsHoy) { const parts = (item.horario || '').split('-'); @@ -331,7 +331,7 @@ export class HomePage implements OnInit { // 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); + const favsManana = this.horarios.filter(h => this.isFavorito(h) && this.DIAS_DOW[h.dia || ''] === mananaIdx); for (const item of favsManana) { const mInicio = (item.horario || '').match(/(\d{1,2}):(\d{2})/); @@ -456,12 +456,36 @@ export class HomePage implements OnInit { this.filterHorarios(); } - isFavorito(codigo: string): boolean { - return this.favoritos.includes(codigo); + getItemKey(item: ClaseHorario): string { + if (!item) return ''; + return `${item.codigo}::${item.asignatura.trim()}`; + } + + isFavorito(item: ClaseHorario | string): boolean { + if (!item) return false; + if (typeof item === 'string') { + return this.favoritos.some(fav => fav === item || fav.startsWith(`${item}::`)); + } + + const itemKey = this.getItemKey(item); + + // 1. Coincidencia directa por clave específica (ej: "ASIG00127::Programación de Vanguardia - Equipo 1") + if (this.favoritos.includes(itemKey)) return true; + + // 2. Coincidencia por código base (ej: "ASIG00125") + if (this.favoritos.includes(item.codigo)) { + // Si en favoritos existe una clave más específica para este código (ej: "ASIG00127::..."), + // el código plano NO debe marcar a las demás variantes + const hasSpecificForCode = this.favoritos.some(fav => fav.startsWith(`${item.codigo}::`)); + return !hasSpecificForCode; + } + + return false; } async toggleFavorito(item: ClaseHorario) { - const isFav = this.isFavorito(item.codigo); + const isFav = this.isFavorito(item); + const itemKey = this.getItemKey(item); const alert = await this.alertCtrl.create({ header: isFav ? 'Quitar de Favoritos' : 'Agregar a Favoritos', @@ -477,9 +501,22 @@ export class HomePage implements OnInit { text: 'Aceptar', handler: async () => { if (isFav) { - this.favoritos = this.favoritos.filter(id => id !== item.codigo); + // Remover tanto la clave específica como el código genérico si existía + this.favoritos = this.favoritos.filter(id => id !== itemKey && id !== item.codigo); } else { - this.favoritos.push(item.codigo); + // Verificar si hay múltiples filas con el mismo código en la cartelera (ej. Equipo 1 y Equipo 2) + const sameCodeCount = this.horarios.filter(h => h.codigo === item.codigo).length; + if (sameCodeCount > 1) { + // Hay múltiples equipos/comisiones: guardar clave específica para esta materia y equipo + if (!this.favoritos.includes(itemKey)) { + this.favoritos.push(itemKey); + } + } else { + // Código único: guardar código base + if (!this.favoritos.includes(item.codigo) && !this.favoritos.includes(itemKey)) { + this.favoritos.push(item.codigo); + } + } } await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) }); this.guardarProgreso(); @@ -572,7 +609,7 @@ export class HomePage implements OnInit { async openMateriaActions(item: ClaseHorario) { // Si estamos en cartelera general y NO es favorito, lo agregamos directamente - if (this.viewMode === 'todas' && !this.isFavorito(item.codigo)) { + if (this.viewMode === 'todas' && !this.isFavorito(item)) { this.toggleFavorito(item); return; } @@ -1073,32 +1110,56 @@ export class HomePage implements OnInit { // --- FAVORITOS: Sincronización Inteligente --- if (this.horarios && this.horarios.length > 0) { - // Prioridad 1: Por Código Exacto (Desde la página de Inscripción / Cursada) + // Prioridad 1: Por Código e Identificación Precisa de Equipo/Comisión (Desde Inscripciones Activas de SIU) if (inscripcionesSiu.length > 0) { for (const insc of inscripcionesSiu) { - // Buscamos en nuestra cartelera si existe este código ASIG - const matchHorarios = this.horarios.filter(h => h.codigo === insc.codigo); - for (const h of matchHorarios) { - if (!this.favoritos.includes(h.codigo)) { - this.favoritos = [...this.favoritos, h.codigo]; - changed = true; + const matchedHorario = this.encontrarHorarioParaInscripcionSiu(insc, this.horarios); + if (matchedHorario) { + const itemKey = this.getItemKey(matchedHorario); + const sameCodeItems = this.horarios.filter(h => h.codigo === matchedHorario.codigo); + + if (sameCodeItems.length > 1) { + // Si hay múltiples equipos/comisiones (ej. Equipo 1 y Equipo 2): + // Removemos el código genérico u otros equipos de este mismo código para dejar solo el correcto + const oldFavsCount = this.favoritos.length; + this.favoritos = this.favoritos.filter(f => f !== matchedHorario.codigo && !sameCodeItems.some(h => f === this.getItemKey(h) && f !== itemKey)); + + if (!this.favoritos.includes(itemKey)) { + this.favoritos.push(itemKey); + changed = true; + } else if (this.favoritos.length !== oldFavsCount) { + changed = true; + } + } else { + // Código único + if (!this.isFavorito(matchedHorario)) { + this.favoritos.push(matchedHorario.codigo); + changed = true; + } } } } } - // Prioridad 2: Por Nombre (Caso de fallback o si no hay códigos en la cartelera todavía) + // Prioridad 2: Por Nombre (Fallback si la materia no vino en inscripciones activas) if (this.siuMaterias && this.siuMaterias.length > 0) { const enCursoEnSiu = this.siuMaterias.filter(m => this.determinarEstadoSiu(m.nota, m.origen) === 'encurso'); for (const siuMat of enCursoEnSiu) { - // Si ya la tenemos por código, omitimos el sync por nombre para evitar duplicados erroneos - const yaEnFavs = this.horarios.some(h => this.favoritos.includes(h.codigo) && this.matcheaSiu(siuMat.nombre, h.asignatura)); + const yaEnFavs = this.horarios.some(h => this.isFavorito(h) && this.matcheaSiu(siuMat.nombre, h.asignatura)); if (yaEnFavs) continue; const matchHorarios = this.horarios.filter(h => this.matcheaSiu(siuMat.nombre, h.asignatura)); - for (const h of matchHorarios) { - if (!this.favoritos.includes(h.codigo)) { - this.favoritos = [...this.favoritos, h.codigo]; + if (matchHorarios.length === 1) { + const h = matchHorarios[0]; + if (!this.isFavorito(h)) { + this.favoritos.push(h.codigo); + changed = true; + } + } else if (matchHorarios.length > 1) { + const h = matchHorarios[0]; + const itemKey = this.getItemKey(h); + if (!this.isFavorito(h)) { + this.favoritos.push(itemKey); changed = true; } } @@ -1115,6 +1176,58 @@ export class HomePage implements OnInit { if (changed) this.guardarProgreso(); } + /** + * Resuelve la discrepancia entre el código con sufijo de SIU (ej: ASIG00127-E1-TD) + * y los registros de la Cartelera (código ASIG00127 con nombre 'Programación de Vanguardia - Equipo 1') + */ + encontrarHorarioParaInscripcionSiu(insc: { nombre: string, codigo: string }, horarios: ClaseHorario[]): ClaseHorario | null { + if (!insc || !horarios || horarios.length === 0) return null; + + // 1. Extraer código base ASIG (ej: de "ASIG00127-E1-TD" extrae "ASIG00127") + const asigMatch = insc.codigo.match(/ASIG\d+/i); + const asigBase = asigMatch ? asigMatch[0].toUpperCase() : ''; + + // 2. Extraer Equipo / Comisión del código SIU (ej: de "ASIG00127-E1-TD" extrae "1") + const siuTeamMatch = insc.codigo.match(/[-_](?:E|EQ|EQUIPO|C|COM|COMISION)\s*([0-9A-Za-z]+)/i); + const siuTeam = siuTeamMatch ? siuTeamMatch[1].toUpperCase() : null; + + // 3. Filtrar candidatos por código ASIG base o por coincidencia de nombre + let candidatos = horarios.filter(h => { + const codeMatch = asigBase && h.codigo.toUpperCase() === asigBase; + const nameMatch = this.matcheaSiu(insc.nombre, h.asignatura); + return codeMatch || nameMatch; + }); + + if (candidatos.length === 0) return null; + if (candidatos.length === 1) return candidatos[0]; + + // 4. Si hay múltiples candidatos (ej. Equipo 1 y Equipo 2): + if (siuTeam) { + // Buscar el candidato cuyo nombre de asignatura coincida con el Equipo/Comisión (ej: "Equipo 1", "Eq 1", "Comisión 1") + const candidatoEquipo = candidatos.find(h => { + const sheetTeamMatch = h.asignatura.match(/(?:Equipo|Eq\.?|Comisi[oó]n|Com\.?)\s*([0-9A-Za-z]+)/i); + const sheetTeam = sheetTeamMatch ? sheetTeamMatch[1].toUpperCase() : null; + return sheetTeam === siuTeam; + }); + + if (candidatoEquipo) return candidatoEquipo; + } + + // 5. Fallback por carrera si está disponible (ej: TD -> TEC. DIG.) + if (insc.codigo.toUpperCase().includes('-TD')) { + const candidatoCarrera = candidatos.find(h => h.carrera.toUpperCase().includes('TEC') || h.carrera.toUpperCase().includes('DIG')); + if (candidatoCarrera) return candidatoCarrera; + } else if (insc.codigo.toUpperCase().includes('-CD')) { + const candidatoCarrera = candidatos.find(h => h.carrera.toUpperCase().includes('DATOS')); + if (candidatoCarrera) return candidatoCarrera; + } else if (insc.codigo.toUpperCase().includes('-VJ') || insc.codigo.toUpperCase().includes('-VD')) { + const candidatoCarrera = candidatos.find(h => h.carrera.toUpperCase().includes('VIDEO')); + if (candidatoCarrera) return candidatoCarrera; + } + + return candidatos[0]; + } + autoAsignarOptativasSiu(): boolean { if (!this.siuMaterias) return false; let mappedAlgo = false; diff --git a/src/app/services/credencial.service.ts b/src/app/services/credencial.service.ts index f51dfda..b1f5142 100644 --- a/src/app/services/credencial.service.ts +++ b/src/app/services/credencial.service.ts @@ -316,7 +316,7 @@ export class CredencialService { const page = await pdf.getPage(1); - const scale = 2.0; + const scale = 3.0; const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); diff --git a/src/app/services/data.ts b/src/app/services/data.ts index 0b935b1..7f6bdec 100644 --- a/src/app/services/data.ts +++ b/src/app/services/data.ts @@ -185,11 +185,22 @@ export class DataService { return (hash % 2_000_000) + 100_000; } + private isItemInFavorites(item: ClaseHorario, favoritos: string[]): boolean { + const itemKey = `${item.codigo}::${item.asignatura.trim()}`; + if (favoritos.includes(itemKey)) return true; + if (favoritos.includes(item.codigo)) { + const hasSpecific = favoritos.some(f => f.startsWith(`${item.codigo}::`)); + return !hasSpecific; + } + return false; + } + private async checkRoomChanges(oldData: ClaseHorario[], newData: ClaseHorario[], favoritos: string[]) { for (const item of newData) { - if (favoritos.includes(item.codigo)) { + if (this.isItemInFavorites(item, favoritos)) { const oldItem = oldData.find(o => o.codigo === item.codigo && + o.asignatura === item.asignatura && o.dia === item.dia && o.horario === item.horario ); diff --git a/src/app/services/siu-guarani.service.ts b/src/app/services/siu-guarani.service.ts index 30ddda7..cccd685 100644 --- a/src/app/services/siu-guarani.service.ts +++ b/src/app/services/siu-guarani.service.ts @@ -7,7 +7,7 @@ import { AuthService } from './auth.service'; @Injectable({ providedIn: 'root' }) export class SiuService { constructor(private authService: AuthService) {} - private readonly BASE_URL = 'https://siuuniciu.eastus2.cloudapp.azure.com/caba'; + private readonly BASE_URL = 'https://autogestion.udelaciudad.edu.ar/caba'; async getAcademicHistory(dni: string, pass: string, carreraId: string = '30'): Promise { try { @@ -49,7 +49,27 @@ export class SiuService { const rawHtml = resCursada.data; - // Buscamos el bloque on_arrival que contiene "info_materia" (donde están las inscripciones) + // 1. Extraer directamente de "notificaciones" donde SIU provee el array JSON "asignaciones" + const regexNotif = /on_arrival\((\{.*?"id":"notificaciones".*?\})\);/s; + const matchNotif = rawHtml.match(regexNotif); + if (matchNotif && matchNotif[1]) { + try { + const jsonNotif = JSON.parse(matchNotif[1]); + const asignaciones = jsonNotif.info?.asignaciones; + if (Array.isArray(asignaciones) && asignaciones.length > 0) { + return asignaciones.map((asig: any) => ({ + nombre: asig.NOMBRE_MATERIA || asig.MATERIA, + codigo: asig.NOMBRE || asig.MATERIA, // ej: "ASIG00127-E1-TD" + comision: asig.COMISION, + dia: asig.DIA_SEMANA + })); + } + } catch (e) { + console.warn('No se pudieron leer asignaciones estructuradas:', e); + } + } + + // 2. Fallback: extraer del bloque HTML "info_materia" (tabla .js-mis-inscripciones) const regex = /on_arrival\((\{.*?"id":"info_materia".*?\})\);/s; const match = rawHtml.match(regex); @@ -59,7 +79,7 @@ export class SiuService { return this.parseInscripcionesActivasHtml(contentHtml); } - return []; // Quizás no tiene inscripciones activas + return []; } catch (error) { console.error('Error SIU Active Inscriptions:', error); return []; @@ -254,20 +274,41 @@ export class SiuService { private parseInscripcionesActivasHtml(html: string): any[] { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); - // Buscamos los elementos que contienen los nombres de las materias (según el HTML del usuario) - const rows = Array.from(doc.querySelectorAll('.js-mis-inscripciones td')); + // Buscamos los elementos que contienen las inscripciones activas + const rows = Array.from(doc.querySelectorAll('.js-mis-inscripciones td, .inscripciones-lista td, .cuadro_materias td, table.tabla_cursada td')); - return rows.map(td => { + const results: any[] = []; + for (const td of rows) { const text = td.textContent?.trim() || ''; - // Formato esperado: "Nombre Materia - ASIG00125" - const parts = text.split(' - '); - if (parts.length >= 2) { + if (!text || text.length < 5) continue; + + // Caso 1: "Nombre Materia - ASIG00127-E1-TD" + if (text.includes(' - ')) { + const parts = text.split(' - '); const codigo = parts[parts.length - 1].trim(); const nombre = parts.slice(0, -1).join(' - ').trim(); - return { nombre, codigo }; + if (codigo.toUpperCase().includes('ASIG') || codigo.length >= 4) { + results.push({ nombre, codigo }); + continue; + } } - return null; - }).filter(item => item !== null); + + // Caso 2: "Nombre Materia (ASIG00127-E1-TD)" + const matchParen = text.match(/^(.*?)\s*\((ASIG[^\)]+)\)/i); + if (matchParen) { + results.push({ nombre: matchParen[1].trim(), codigo: matchParen[2].trim() }); + continue; + } + + // Caso 3: contiene código ASIG embebido + const matchAsig = text.match(/(ASIG[A-Z0-9\-_]+)/i); + if (matchAsig) { + const codigo = matchAsig[1].trim(); + const nombre = text.replace(codigo, '').replace(/[-–()]/g, '').trim(); + results.push({ nombre, codigo }); + } + } + return results; } private parseHtmlSubjects(html: string): any[] {