From 678cc9898fd4d0e694244a69745de66ec0032f18 Mon Sep 17 00:00:00 2001 From: Carlos Tello Date: Sun, 29 Mar 2026 23:03:40 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20sincronizaci=C3=B3n=20avanzada=20SIU=20?= =?UTF-8?q?por=20c=C3=B3digo=20ASIG,=20perfil=20con=20foto/nombre=20de=20M?= =?UTF-8?q?oodle=20y=20refinamiento=20visual=20violeta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/app-routing.module.ts | 4 + .../credencial-modal.component.html | 7 +- .../credencial-modal.component.ts | 22 +- src/app/home/home.page.html | 67 ++---- src/app/home/home.page.ts | 189 +++++++++-------- src/app/perfil/perfil-routing.module.ts | 16 ++ src/app/perfil/perfil.module.ts | 17 ++ src/app/perfil/perfil.page.html | 127 ++++++++++++ src/app/perfil/perfil.page.scss | 194 ++++++++++++++++++ src/app/perfil/perfil.page.ts | 184 +++++++++++++++++ src/app/services/auth.service.ts | 120 +++++++++++ src/app/services/credencial.service.ts | 167 +++++++++------ src/app/services/siu-guarani.service.ts | 81 ++++++-- 13 files changed, 972 insertions(+), 223 deletions(-) create mode 100644 src/app/perfil/perfil-routing.module.ts create mode 100644 src/app/perfil/perfil.module.ts create mode 100644 src/app/perfil/perfil.page.html create mode 100644 src/app/perfil/perfil.page.scss create mode 100644 src/app/perfil/perfil.page.ts create mode 100644 src/app/services/auth.service.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index e7b0d5a..313baa6 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -6,6 +6,10 @@ const routes: Routes = [ path: 'home', loadChildren: () => import('./home/home.module').then( m => m.HomePageModule) }, + { + path: 'perfil', + loadChildren: () => import('./perfil/perfil.module').then( m => m.PerfilPageModule) + }, { path: '', redirectTo: 'home', diff --git a/src/app/components/credencial-modal/credencial-modal.component.html b/src/app/components/credencial-modal/credencial-modal.component.html index 69c0a65..b0ba1cb 100644 --- a/src/app/components/credencial-modal/credencial-modal.component.html +++ b/src/app/components/credencial-modal/credencial-modal.component.html @@ -12,7 +12,12 @@
-

Conectando con el campus y generando credencial...

+
+

{{ loadingStatus }}

+

+ Por favor no cierres la aplicación +

+
diff --git a/src/app/components/credencial-modal/credencial-modal.component.ts b/src/app/components/credencial-modal/credencial-modal.component.ts index 17cf74b..2f2fbb7 100644 --- a/src/app/components/credencial-modal/credencial-modal.component.ts +++ b/src/app/components/credencial-modal/credencial-modal.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { IonicModule, ModalController, ToastController } from '@ionic/angular'; import { FormsModule } from '@angular/forms'; import { CredencialService } from '../../services/credencial.service'; +import { AuthService } from '../../services/auth.service'; import { ScreenBrightness } from '@capacitor-community/screen-brightness'; type CredencialState = 'loading' | 'login' | 'success' | 'error'; @@ -18,6 +19,7 @@ export class CredencialModalComponent implements OnInit, OnDestroy { state: CredencialState = 'loading'; errorMessage: string = ''; + loadingStatus: string = 'Iniciando...'; dni: string = ''; password: string = ''; @@ -29,6 +31,7 @@ export class CredencialModalComponent implements OnInit, OnDestroy { constructor( private modalCtrl: ModalController, private credencialService: CredencialService, + private authService: AuthService, private toastCtrl: ToastController ) { } @@ -45,28 +48,29 @@ export class CredencialModalComponent implements OnInit, OnDestroy { ngOnDestroy() { if (this.originalBrightness !== null) { - ScreenBrightness.setBrightness({ brightness: this.originalBrightness }).catch(() => {}); + ScreenBrightness.setBrightness({ brightness: this.originalBrightness }).catch(() => { }); } } async checkExistingCredential() { this.state = 'loading'; + this.loadingStatus = 'Comprobando caché...'; try { // 1. Try to load cached image first for instant load const cachedImg = await this.credencialService.getCachedImage(); if (cachedImg) { this.credentialImage = cachedImg; this.state = 'success'; - + // Even if we show cache, load credentials to fields in background in case they want to update - const stored = await this.credencialService.getStoredCredentials(); + const stored = await this.authService.getMoodleCredentials(); this.dni = stored.dni || ''; this.password = stored.pass || ''; return; } // 2. If no cached image, check for credentials to auto-login - const stored = await this.credencialService.getStoredCredentials(); + const stored = await this.authService.getMoodleCredentials(); if (stored.dni && stored.pass) { this.dni = stored.dni; this.password = stored.pass; @@ -86,9 +90,13 @@ export class CredencialModalComponent implements OnInit, OnDestroy { this.state = 'loading'; try { - this.credentialImage = await this.credencialService.generateCredential(this.dni, this.password); + this.credentialImage = await this.credencialService.generateCredential( + this.dni, + this.password, + (status) => { this.loadingStatus = status; } + ); this.state = 'success'; - + if (forceUpdate) { const toast = await this.toastCtrl.create({ message: 'Credencial actualizada correctamente', @@ -105,7 +113,7 @@ export class CredencialModalComponent implements OnInit, OnDestroy { } async logout() { - await this.credencialService.clearAll(); + await this.authService.logoutMoodle(); this.dni = ''; this.password = ''; this.credentialImage = ''; diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html index b9b77b1..c48307b 100644 --- a/src/app/home/home.page.html +++ b/src/app/home/home.page.html @@ -1,15 +1,17 @@
- + - Cartelera de Aulas + UNICABA + + + @@ -207,12 +209,10 @@
Plan de Estudios / Recorrido
- + - - -

Selecciona tu carrera para guardar tu avance y ver el recorrido sugerido, toca materias @@ -244,7 +244,8 @@ [class.encurso]="isEnCurso(row.c1.name)" [class.optativa-cell]="row.c1.isOptativa" (click)="handleCellClick(row.c1)"> {{ abreviarMateria(row.c1.name) }} - + {{ getSiuBadgeText(siuMat) }}

@@ -254,7 +255,8 @@ [class.encurso]="isEnCurso(row.c2.name)" [class.optativa-cell]="row.c2.isOptativa" (click)="handleCellClick(row.c2)"> {{ abreviarMateria(row.c2.name) }} - + {{ getSiuBadgeText(siuMat) }} @@ -264,7 +266,8 @@ [class.encurso]="isEnCurso(row.c3.name)" [class.optativa-cell]="row.c3.isOptativa" (click)="handleCellClick(row.c3)"> {{ abreviarMateria(row.c3.name) }} - + {{ getSiuBadgeText(siuMat) }} @@ -276,50 +279,6 @@ - - diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts index 3a85e36..fb344db 100644 --- a/src/app/home/home.page.ts +++ b/src/app/home/home.page.ts @@ -1,5 +1,5 @@ import { Component, OnInit } from '@angular/core'; -import { ToastController, ModalController, AlertController, ActionSheetController, IonicSafeString } from '@ionic/angular'; +import { ToastController, ModalController, AlertController, ActionSheetController, NavController } from '@ionic/angular'; import { DataService, ClaseHorario, EventoAcademico } from '../services/data'; import { Preferences } from '@capacitor/preferences'; @@ -14,6 +14,7 @@ import { EventosService } from '../services/eventos.service'; import { EventoModalComponent } from '../components/evento-modal/evento-modal.component'; import { AgendaModalComponent } from '../components/agenda-modal/agenda-modal.component'; import { SiuService } from '../services/siu-guarani.service'; +import { AuthService } from '../services/auth.service'; export interface ProximaClaseInfo { @@ -67,13 +68,9 @@ export class HomePage implements OnInit { carreraSeleccionada: string = 'tecnologias'; - // SIU Guaraní State + // SIU Guaraní State (Datos persistidos) siuMaterias: any[] = []; siuLastSync: string | null = null; - isSiuModalOpen: boolean = false; - siuLoading: boolean = false; - siuDni: string = ''; - siuPass: string = ''; // Mapa de días en español al getDay() de JS (0=Dom) @@ -91,7 +88,9 @@ export class HomePage implements OnInit { private localStorageService: LocalStorageService, private actionSheetCtrl: ActionSheetController, private eventosService: EventosService, - private siuService: SiuService + private siuService: SiuService, + private authService: AuthService, + private navCtrl: NavController ) { } @@ -108,6 +107,30 @@ export class HomePage implements OnInit { this.loadInitialData(); } + async ionViewWillEnter() { + // Recargamos datos de progreso por si volvimos desde Perfil + const userData = await this.localStorageService.loadData(); + if (userData) { + this.siuMaterias = userData.siuMaterias || []; + this.siuLastSync = userData.siuLastSync || null; + this.aprobadas = userData.aprobadas || []; + this.encurso = userData.encurso || []; + this.favoritos = userData.favoritos || []; + this.optativasSeleccionadas = userData.optativas || []; + + // Crucial: Recargar los eventos al objeto local + this.eventos = await this.eventosService.loadEventos(); + + // Disparamos la lógica de auto-completar para mapear optativas y estados académicos + if (this.siuMaterias.length > 0) { + await this.autoCompletarDesdeSiu(); + } + + this.filterHorarios(); + this.actualizarProximaClase(); + } + } + async loadInitialData() { // 0. Sincronizar permisos secuencialmente al iniciar la app para evitar que un popup de Android anule a otro try { @@ -133,7 +156,7 @@ export class HomePage implements OnInit { // Auto-completar desde SIU si hay datos previamente cacheados if (this.siuMaterias.length > 0) { - this.autoCompletarDesdeSiu(); + await this.autoCompletarDesdeSiu(); } // Sincronizar eventos cargados desde el CSV con el servicio de eventos @@ -484,15 +507,6 @@ export class HomePage implements OnInit { if (isFav) { this.favoritos = this.favoritos.filter(id => id !== item.codigo); } else { - if (this.favoritos.length >= 5) { - const toast = await this.toastCtrl.create({ - message: 'Puedes seleccionar un máximo de 5 materias favoritas', - duration: 3000, - color: 'warning' - }); - toast.present(); - return; - } this.favoritos.push(item.codigo); } await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) }); @@ -567,13 +581,6 @@ export class HomePage implements OnInit { window.open(url, '_system'); } - async openCredencial() { - const modal = await this.modalCtrl.create({ - component: CredencialModalComponent - }); - - await modal.present(); - } // --- Hitos Académicos (Agenda) --- @@ -1005,56 +1012,8 @@ export class HomePage implements OnInit { } } - // --- SIU Guaraní --- - - openSiuModal() { - this.isSiuModalOpen = true; - } - - closeSiuModal() { - this.isSiuModalOpen = false; - this.siuDni = ''; - this.siuPass = ''; - } - - async sincronizarSiu() { - if (!this.siuDni || !this.siuPass) { - const toast = await this.toastCtrl.create({ - message: 'Por favor, ingresá DNI y Contraseña para conectar', - duration: 2500, - color: 'warning' - }); - toast.present(); - return; - } - - this.siuLoading = true; - try { - const materias = await this.siuService.getAcademicHistory(this.siuDni, this.siuPass); - this.siuMaterias = materias; - this.siuLastSync = new Date().toISOString(); - - this.autoCompletarDesdeSiu(); - this.guardarProgreso(); - - const toast = await this.toastCtrl.create({ - message: 'Historial del SIU cargado con éxito!', - duration: 3000, - color: 'success' - }); - toast.present(); - this.closeSiuModal(); - } catch (error) { - console.error(error); - const toast = await this.toastCtrl.create({ - message: 'Error al conectar con SIU. Comprobá tus datos o intentá más tarde.', - duration: 4000, - color: 'danger' - }); - toast.present(); - } finally { - this.siuLoading = false; - } + goToPerfil() { + this.navCtrl.navigateForward('/perfil'); } getNotaColor(nota: string): string { @@ -1084,7 +1043,17 @@ export class HomePage implements OnInit { const siuN = this.normalizeText(siuName); const appN = this.normalizeText(this.getCleanSubjectName(appName)); if (!siuN || !appN) return false; - return siuN === appN || appN.includes(siuN) || siuN.includes(appN); + + // Prioridad 1: Coincidencia Exacta + if (siuN === appN) return true; + + // Prioridad 2: Si una contiene a la otra PERO con un margen de seguridad (nombres largos) + // Evita que "Programación" coincida con "Programación de Vanguardia" si no son lo mismo + if (siuN.length > 8 && appN.length > 8) { + if (siuN.includes(appN) || appN.includes(siuN)) return true; + } + + return false; } getSiuMateriaParaCelda(materiaNombre: string): any { @@ -1146,25 +1115,41 @@ export class HomePage implements OnInit { }); } - determinarEstadoSiu(nota: string, origen: string): 'aprobada' | 'encurso' | null { + determinarEstadoSiu(nota: string, origen: string): 'aprobada' | 'regular' | 'encurso' | null { const n = parseInt(nota, 10); const lowerNota = (nota || '').toLowerCase(); const lowerOrigen = (origen || '').toLowerCase(); - // Si la nota es numérica y es mayor o igual a 4, está aprobada + // 1. Aprobada (Nota >= 4 o texto explícito) if (!isNaN(n) && n >= 4) return 'aprobada'; if (lowerNota.includes('aprobado') || lowerNota.includes('promocionado') || lowerNota.includes('equivalencia')) return 'aprobada'; if (lowerOrigen.includes('aprobado') || lowerOrigen.includes('promocionado')) return 'aprobada'; - // Si no está aprobada, pero cursa regular o está anotado - if (lowerOrigen.includes('en curso') || lowerNota.includes('en curso') || lowerOrigen.includes('regular')) return 'encurso'; + // 2. En Curso (Actualidad) + if (lowerOrigen.includes('en curso') || lowerNota.includes('en curso')) return 'encurso'; + + // 3. Regular (Cursada terminada sin final) + if (lowerOrigen.includes('regular') || lowerNota.includes('regular')) return 'regular'; return null; } - autoCompletarDesdeSiu() { + async autoCompletarDesdeSiu() { let changed = false; + // 0. Obtener Credenciales y Datos Detallados de Inscripción + const creds = await this.authService.getSiuCredentials(); + let inscripcionesSiu: any[] = []; + + if (creds.dni && creds.pass) { + try { + inscripcionesSiu = await this.siuService.getInscripcionesActivas(creds.dni, creds.pass); + console.log('Inscripciones activas SIU:', inscripcionesSiu); + } catch (e) { + console.warn('No se pudieron obtener inscripciones activas:', e); + } + } + // Primero, pre-mapear optativas huérfanas const newlyMapped = this.autoAsignarOptativasSiu(); if (newlyMapped) changed = true; @@ -1184,12 +1169,54 @@ export class HomePage implements OnInit { this.aprobadas = [...this.aprobadas, appName]; this.encurso = this.encurso.filter(x => x !== appName); changed = true; - } else if (estadoSiu === 'encurso' && !this.aprobadas.includes(appName) && !this.encurso.includes(appName)) { + } else if ((estadoSiu === 'encurso' || estadoSiu === 'regular') && !this.aprobadas.includes(appName) && !this.encurso.includes(appName)) { this.encurso = [...this.encurso, appName]; changed = true; } } } + + // --- 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) + 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; + } + } + } + } + + // Prioridad 2: Por Nombre (Caso de fallback o si no hay códigos en la cartelera todavía) + 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)); + 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]; + changed = true; + } + } + } + } + + if (changed) { + // Actualizamos preferencias específicamente para favoritos + await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) }); + this.filterHorarios(); + } + } + if (changed) this.guardarProgreso(); } diff --git a/src/app/perfil/perfil-routing.module.ts b/src/app/perfil/perfil-routing.module.ts new file mode 100644 index 0000000..89b9061 --- /dev/null +++ b/src/app/perfil/perfil-routing.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { PerfilPage } from './perfil.page'; + +const routes: Routes = [ + { + path: '', + component: PerfilPage + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class PerfilPageRoutingModule {} diff --git a/src/app/perfil/perfil.module.ts b/src/app/perfil/perfil.module.ts new file mode 100644 index 0000000..cd1eede --- /dev/null +++ b/src/app/perfil/perfil.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { IonicModule } from '@ionic/angular'; +import { PerfilPageRoutingModule } from './perfil-routing.module'; +import { PerfilPage } from './perfil.page'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + IonicModule, + PerfilPageRoutingModule + ], + declarations: [PerfilPage] +}) +export class PerfilPageModule {} diff --git a/src/app/perfil/perfil.page.html b/src/app/perfil/perfil.page.html new file mode 100644 index 0000000..7e3ec99 --- /dev/null +++ b/src/app/perfil/perfil.page.html @@ -0,0 +1,127 @@ + + + + + + Configuración de Perfil + + + + +
+
+
+ + +
+

{{ authState?.userName }}

+

Gestión de Cuentas

+

Unificá tus credenciales de SIU y Campus

+
+
+ +
+ + + +
+ + SIU Guaraní + Conectado + Sin Vincular +
+ Historia Académica y Cartelera Personalizada +
+ + +

Conectá con el SIU para traer tu historial y que la app auto-favoritee tus materias en curso.

+ + + + + + + + + + + + + + + + Sincronizar SIU + +

{{ authState?.siuError }}

+
+ + +
+ +
+ Cuenta Vinculada + DNI: {{ siuDni }} +
+
+ + + Cerrar sesión SIU + +
+
+ + + + +
+ + Campus UNICABA + Conectado + Sin Vincular +
+ Credencial Digital y Aulas Virtuales +
+ + +

Vinculá tu cuenta del campus para generar tu credencial digital offline y habilitar alertas de actividades.

+ + + + + + + + + + + + + + + + Vincular Credencial + +

{{ authState?.moodleError }}

+
+ + +
+ +
+ Cuenta Vinculada + DNI: {{ moodleDni }} +
+
+ + + Cerrar sesión Campus + +
+
+ +
+ +

Tus credenciales se almacenan de forma segura y local en tu dispositivo. No son enviadas a servidores externos ajenos a la universidad.

+
+
+
diff --git a/src/app/perfil/perfil.page.scss b/src/app/perfil/perfil.page.scss new file mode 100644 index 0000000..a18e94e --- /dev/null +++ b/src/app/perfil/perfil.page.scss @@ -0,0 +1,194 @@ +.profile-header-gradient { + background: linear-gradient(135deg, var(--ion-color-secondary) 0%, var(--ion-color-primary) 100%); + padding: 40px 20px 60px 20px; + text-align: center; + color: white; + border-bottom-left-radius: 40px; + border-bottom-right-radius: 40px; + box-shadow: 0 10px 30px rgba(var(--ion-color-primary-rgb), 0.3); + + .user-avatar-container { + .avatar-circle { + width: 80px; + height: 80px; + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(10px); + border-radius: 50%; + margin: 0 auto 15px auto; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid rgba(255, 255, 255, 0.3); + overflow: hidden; + + ion-icon { + font-size: 50px; + color: white; + } + + .user-photo-profile { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; + } + } + + h2 { + margin: 0; + font-weight: 700; + font-size: 1.6rem; + } + + p { + margin: 5px 0 0 0; + opacity: 0.9; + font-size: 0.95rem; + } + } +} + +.content-container { + margin-top: -30px; + padding: 0 16px 20px 16px; +} + +.auth-card { + border-radius: 20px; + margin-bottom: 20px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08); + border: 1px solid rgba(0, 0, 0, 0.03); + overflow: hidden; + transition: all 0.3s ease; + + &.connected { + border-left: 6px solid var(--ion-color-primary); + } + + ion-card-header { + padding-bottom: 5px; + + .card-title-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 6px; + + .service-icon { + font-size: 28px; + } + + ion-card-title { + font-weight: 700; + font-size: 1.2rem; + flex: 1; + } + + .status-badge { + font-size: 0.75rem; + padding: 6px 10px; + border-radius: 20px; + } + } + + ion-card-subtitle { + font-size: 0.85rem; + letter-spacing: 0.5px; + } + } + + .auth-desc { + font-size: 0.9rem; + color: var(--ion-color-medium); + margin-bottom: 15px; + } + + .auth-form { + background: transparent; + padding: 0; + + ion-item { + --padding-start: 10px; + --inner-padding-end: 10px; + --border-radius: 12px; + background: var(--ion-color-light); + margin-bottom: 8px; + --border-color: rgba(0, 0, 0, 0.05); + + ion-icon { + margin-right: 10px; + } + } + } + + .btn-connect { + margin-top: 20px; + --border-radius: 12px; + font-weight: 600; + height: 48px; + box-shadow: 0 4px 15px rgba(var(--ion-color-primary-rgb), 0.2); + } + + .connected-info { + display: flex; + align-items: center; + gap: 15px; + background: rgba(var(--ion-color-primary-rgb), 0.1); + padding: 15px; + border-radius: 15px; + margin-bottom: 10px; + + ion-icon { + font-size: 32px; + } + + .text { + display: flex; + flex-direction: column; + + strong { + font-size: 1rem; + color: var(--ion-color-dark); + } + + span { + font-size: 0.85rem; + color: var(--ion-color-medium); + } + } + } + + .btn-logout { + --color: var(--ion-color-danger); + font-weight: 500; + font-size: 0.9rem; + } +} + +.error-text { + color: var(--ion-color-danger); + font-size: 0.85rem; + margin-top: 10px; + font-weight: 500; + text-align: center; +} + +.security-info { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 10px; + opacity: 0.7; + + ion-icon { + font-size: 30px; + } + + p { + font-size: 0.8rem; + margin: 0; + line-height: 1.4; + max-width: 250px; + } +} diff --git a/src/app/perfil/perfil.page.ts b/src/app/perfil/perfil.page.ts new file mode 100644 index 0000000..40bffcc --- /dev/null +++ b/src/app/perfil/perfil.page.ts @@ -0,0 +1,184 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { ToastController, LoadingController, AlertController, NavController } from '@ionic/angular'; +import { AuthService, AuthState } from '../services/auth.service'; +import { SiuService } from '../services/siu-guarani.service'; +import { CredencialService } from '../services/credencial.service'; +import { LocalStorageService, AppData } from '../services/local-storage.service'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-perfil', + templateUrl: './perfil.page.html', + styleUrls: ['./perfil.page.scss'], + standalone: false +}) +export class PerfilPage implements OnInit, OnDestroy { + authState: AuthState | null = null; + private authSub: Subscription | null = null; + + // Form states + siuDni: string = ''; + siuPass: string = ''; + showSiuPass: boolean = false; + + moodleDni: string = ''; + moodlePass: string = ''; + showMoodlePass: boolean = false; + + loading: boolean = false; + + constructor( + private authService: AuthService, + private siuService: SiuService, + private credencialService: CredencialService, + private localStorageService: LocalStorageService, + private toastCtrl: ToastController, + private loadingCtrl: LoadingController, + private alertCtrl: AlertController, + private navCtrl: NavController + ) { } + + ngOnInit() { + this.authSub = this.authService.authState$.subscribe(state => { + this.authState = state; + // Pre-fill DNI if already connected + if (state.siuConnected && !this.siuDni) this.siuDni = state.siuDni || ''; + if (state.moodleConnected && !this.moodleDni) this.moodleDni = state.moodleDni || ''; + }); + } + + ngOnDestroy() { + if (this.authSub) this.authSub.unsubscribe(); + } + + async loginSiu() { + if (!this.siuDni || !this.siuPass) { + this.showToast('Por favor, ingresá DNI y Contraseña', 'warning'); + return; + } + + const loader = await this.loadingCtrl.create({ message: 'Validando con SIU Guaraní...' }); + await loader.present(); + + try { + const materias = await this.siuService.getAcademicHistory(this.siuDni, this.siuPass); + await this.authService.saveSiuCredentials(this.siuDni, this.siuPass); + + // Persistir datos en LocalStorage para que Home los vea + const currentData = await this.localStorageService.loadData() || this.getDefaultData(); + currentData.siuMaterias = materias; + currentData.siuLastSync = new Date().toISOString(); + + // Aplicar auto-favoritos (opcional, pero recomendado para consistencia) + // Nota: Aquí se podría llamar a una versión compartida de autoCompletarDesdeSiu + // Por ahora guardamos para que Home lo procese al volver + await this.localStorageService.saveData(currentData); + + this.showToast('¡SIU Guaraní conectado y sincronizado!', 'success'); + this.siuPass = ''; + } catch (e: any) { + console.error(e); + this.authService.setSiuError(e.message || 'Error de conexión'); + this.showToast('Error al conectar con SIU: ' + (e.message || 'Credenciales inválidas'), 'danger'); + } finally { + await loader.dismiss(); + } + } + + async logoutSiu() { + const alert = await this.alertCtrl.create({ + header: 'Cerrar Sesión en SIU', + message: '¿Estás seguro de que deseas desconectar tu cuenta de SIU Guaraní? Se borrarán tus credenciales guardadas.', + buttons: [ + { text: 'Cancelar', role: 'cancel' }, + { + text: 'Cerrar Sesión', + role: 'destructive', + handler: () => { + this.authService.logoutSiu(); + this.siuDni = ''; + this.siuPass = ''; + } + } + ] + }); + await alert.present(); + } + + async loginMoodle() { + if (!this.moodleDni || !this.moodlePass) { + this.showToast('Por favor, ingresá DNI y Contraseña (Campus)', 'warning'); + return; + } + + const loader = await this.loadingCtrl.create({ message: 'Validando con Campus UNICABA...' }); + await loader.present(); + + try { + // 1. Validar y generar credencial (descarga PDF) + await this.credencialService.generateCredential(this.moodleDni, this.moodlePass); + + // 2. Obtener Perfil (Nombre y Foto) + const profile = await this.credencialService.getUserProfile(this.moodleDni, this.moodlePass); + await this.authService.saveUserProfile(profile.name, profile.photo); + + // 3. Guardar credenciales de acceso + await this.authService.saveMoodleCredentials(this.moodleDni, this.moodlePass); + + this.showToast('¡Perfil sincronizado y Campus conectado!', 'success'); + this.moodlePass = ''; + } catch (e: any) { + console.error(e); + this.authService.setMoodleError(e.message || 'Error de conexión'); + this.showToast('Error al conectar con Campus: ' + (e.message || 'Credenciales inválidas'), 'danger'); + } finally { + await loader.dismiss(); + } + } + + async logoutMoodle() { + const alert = await this.alertCtrl.create({ + header: 'Cerrar Sesión en Campus', + message: '¿Estás seguro de que deseas desconectar tu cuenta del Campus? Se borrará tu credencial digital cacheada.', + buttons: [ + { text: 'Cancelar', role: 'cancel' }, + { + text: 'Cerrar Sesión', + role: 'destructive', + handler: () => { + this.authService.logoutMoodle(); + this.moodleDni = ''; + this.moodlePass = ''; + } + } + ] + }); + await alert.present(); + } + + goBack() { + this.navCtrl.back(); + } + + private async showToast(message: string, color: 'success' | 'warning' | 'danger') { + const toast = await this.toastCtrl.create({ + message, + duration: 3000, + color, + position: 'bottom' + }); + await toast.present(); + } + + private getDefaultData(): AppData { + return { + favoritos: [], + eventos: [], + aprobadas: [], + encurso: [], + optativas: [], + siuMaterias: [], + siuLastSync: null + }; + } +} diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts new file mode 100644 index 0000000..060c81a --- /dev/null +++ b/src/app/services/auth.service.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@angular/core'; +import { Preferences } from '@capacitor/preferences'; +import { BehaviorSubject } from 'rxjs'; + +export interface AuthState { + siuConnected: boolean; + moodleConnected: boolean; + siuDni: string | null; + moodleDni: string | null; + siuError: string | null; + moodleError: string | null; + userName: string | null; + userPhoto: string | null; // Base64 +} + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private readonly PREF_SIU_DNI = 'auth_siu_dni'; + private readonly PREF_SIU_PASS = 'auth_siu_pass'; + private readonly PREF_MOODLE_DNI = 'auth_moodle_dni'; + private readonly PREF_MOODLE_PASS = 'auth_moodle_pass'; + private readonly PREF_USER_NAME = 'auth_user_name'; + private readonly PREF_USER_PHOTO = 'auth_user_photo'; + + private authState = new BehaviorSubject({ + siuConnected: false, + moodleConnected: false, + siuDni: null, + moodleDni: null, + siuError: null, + moodleError: null, + userName: null, + userPhoto: null + }); + + public authState$ = this.authState.asObservable(); + + constructor() { + this.initialLoad(); + } + + private async initialLoad() { + const siuDni = await Preferences.get({ key: this.PREF_SIU_DNI }); + const siuPass = await Preferences.get({ key: this.PREF_SIU_PASS }); + const moodleDni = await Preferences.get({ key: this.PREF_MOODLE_DNI }); + const moodlePass = await Preferences.get({ key: this.PREF_MOODLE_PASS }); + const userName = await Preferences.get({ key: this.PREF_USER_NAME }); + const userPhoto = await Preferences.get({ key: this.PREF_USER_PHOTO }); + + this.authState.next({ + ...this.authState.value, + siuConnected: !!(siuDni.value && siuPass.value), + moodleConnected: !!(moodleDni.value && moodlePass.value), + siuDni: siuDni.value, + moodleDni: moodleDni.value, + userName: userName.value, + userPhoto: userPhoto.value + }); + } + + async getSiuCredentials() { + const dni = await Preferences.get({ key: this.PREF_SIU_DNI }); + const pass = await Preferences.get({ key: this.PREF_SIU_PASS }); + return { dni: dni.value, pass: pass.value }; + } + + async getMoodleCredentials() { + const dni = await Preferences.get({ key: this.PREF_MOODLE_DNI }); + const pass = await Preferences.get({ key: this.PREF_MOODLE_PASS }); + return { dni: dni.value, pass: pass.value }; + } + + async saveSiuCredentials(dni: string, pass: string) { + await Preferences.set({ key: this.PREF_SIU_DNI, value: dni }); + await Preferences.set({ key: this.PREF_SIU_PASS, value: pass }); + this.updateState({ siuConnected: true, siuDni: dni, siuError: null }); + } + + async saveMoodleCredentials(dni: string, pass: string) { + await Preferences.set({ key: this.PREF_MOODLE_DNI, value: dni }); + await Preferences.set({ key: this.PREF_MOODLE_PASS, value: pass }); + this.updateState({ moodleConnected: true, moodleDni: dni, moodleError: null }); + } + + async saveUserProfile(name: string, photo: string | null) { + if (name) await Preferences.set({ key: this.PREF_USER_NAME, value: name }); + if (photo) await Preferences.set({ key: this.PREF_USER_PHOTO, value: photo }); + this.updateState({ userName: name, userPhoto: photo }); + } + + async logoutSiu() { + await Preferences.remove({ key: this.PREF_SIU_DNI }); + await Preferences.remove({ key: this.PREF_SIU_PASS }); + this.updateState({ siuConnected: false, siuDni: null, siuError: null }); + } + + async logoutMoodle() { + await Preferences.remove({ key: this.PREF_MOODLE_DNI }); + await Preferences.remove({ key: this.PREF_MOODLE_PASS }); + // Al cerrar sesión de moodle, también deberíamos pensar si borramos la imagen de la credencial + await Preferences.remove({ key: 'credencial_base64' }); + await Preferences.remove({ key: this.PREF_USER_NAME }); + await Preferences.remove({ key: this.PREF_USER_PHOTO }); + this.updateState({ moodleConnected: false, moodleDni: null, moodleError: null, userName: null, userPhoto: null }); + } + + setSiuError(error: string | null) { + this.updateState({ siuError: error }); + } + + setMoodleError(error: string | null) { + this.updateState({ moodleError: error }); + } + + private updateState(partial: Partial) { + this.authState.next({ ...this.authState.value, ...partial }); + } +} diff --git a/src/app/services/credencial.service.ts b/src/app/services/credencial.service.ts index d700c8e..f6fb8da 100644 --- a/src/app/services/credencial.service.ts +++ b/src/app/services/credencial.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { CapacitorHttp, HttpResponse } from '@capacitor/core'; +import { AuthService } from './auth.service'; import { Preferences } from '@capacitor/preferences'; import * as pdfjsLib from 'pdfjs-dist'; @@ -15,85 +16,37 @@ export class CredencialService { private readonly LOGIN_URL = 'https://campus.udelaciudad.edu.ar/login/index.php'; private readonly CREDENTIAL_URL = 'https://campus.udelaciudad.edu.ar/mod/customcert/view.php?id=209134&downloadown=1'; - private readonly PREF_DNI = 'credencial_dni'; - private readonly PREF_PASS = 'credencial_pass'; private readonly PREF_IMAGE = 'credencial_base64'; - constructor() {} - - async getStoredCredentials() { - const dni = await Preferences.get({ key: this.PREF_DNI }); - const pass = await Preferences.get({ key: this.PREF_PASS }); - return { dni: dni.value, pass: pass.value }; - } - - async saveCredentials(dni: string, pass: string) { - await Preferences.set({ key: this.PREF_DNI, value: dni }); - await Preferences.set({ key: this.PREF_PASS, value: pass }); - } + constructor(private authService: AuthService) {} async getCachedImage() { const img = await Preferences.get({ key: this.PREF_IMAGE }); return img.value; } - async clearAll() { - await Preferences.remove({ key: this.PREF_DNI }); - await Preferences.remove({ key: this.PREF_PASS }); + async clearCache() { await Preferences.remove({ key: this.PREF_IMAGE }); } /** * Executes the full flow: Login -> Scrape Token -> Submit Login -> Download PDF -> Render Base64 */ - async generateCredential(dni: string, pass: string): Promise { + /** + * Executes the full flow: Login -> Scrape Token -> Submit Login -> Download PDF -> Render Base64 + */ + async generateCredential(dni: string, pass: string, onStatusChange?: (status: string) => void): Promise { try { - // 1. Initial GET to fetch the login page and token - const initialGet: HttpResponse = await CapacitorHttp.get({ - url: this.LOGIN_URL, - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - } - }); - - // Simple DOM parsing to extract logintoken - const parser = new DOMParser(); - const doc = parser.parseFromString(initialGet.data, 'text/html'); - const tokenInput = doc.querySelector('input[name="logintoken"]') as HTMLInputElement; + if (onStatusChange) onStatusChange('Conectando con el campus...'); - if (!tokenInput || !tokenInput.value) { - throw new Error('No se pudo obtener el token de seguridad del campus.'); - } - - const loginToken = tokenInput.value; + await this.loginMoodle(dni, pass); - // 2. POST to login - // CapacitorHttp handles cookies internally, so the session cookie is saved. - // Send form urlencoded - const params = new URLSearchParams(); - params.append('username', dni); - params.append('password', pass); - params.append('logintoken', loginToken); - - const loginPost: HttpResponse = await CapacitorHttp.post({ - url: this.LOGIN_URL, - data: params.toString(), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' - } - }); - - // Verify if login was successful - if (loginPost.data && loginPost.data.includes('loginerrormessage')) { - throw new Error('DNI o Clave incorrectos en el Campus.'); - } + if (onStatusChange) onStatusChange('Descargando PDF...'); // 3. Download the PDF - // We need it as base64 from the native layer to reconstruct the buffer const pdfRes: HttpResponse = await CapacitorHttp.get({ url: this.CREDENTIAL_URL, - responseType: 'arraybuffer', // Capacitor Http will return base64 string for arraybuffer + responseType: 'arraybuffer', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } @@ -104,27 +57,109 @@ export class CredencialService { throw new Error(`El campus no devolvió un PDF. Recibido: ${contentType}`); } - // Capacitor returns base64 string when responseType is arraybuffer or blob const base64Pdf = pdfRes.data; - - // Convert base64 PDF to Uint8Array for pdf.js const pdfData = this.base64ToUint8Array(base64Pdf); + if (onStatusChange) onStatusChange('Generando imagen de credencial (esto puede tardar unos segundos)...'); + // 4. Render PDF to Base64 Image using pdf.js const imgBase64 = await this.renderPdfToBase64(pdfData); // Save to cache securely await Preferences.set({ key: this.PREF_IMAGE, value: imgBase64 }); - // Save credentials for future transparent updates - await this.saveCredentials(dni, pass); - + if (onStatusChange) onStatusChange('¡Listo!'); return imgBase64; } catch (e: any) { throw new Error(e.message || 'Error desconocido al generar la credencial.'); } } + async getUserProfile(dni: string, pass: string): Promise<{name: string, photo: string | null}> { + try { + await this.loginMoodle(dni, pass); + + const profileRes: HttpResponse = await CapacitorHttp.get({ + url: 'https://campus.udelaciudad.edu.ar/user/profile.php', + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + + const parser = new DOMParser(); + const doc = parser.parseFromString(profileRes.data, 'text/html'); + + // 1. Extraer Nombre + const nameTag = doc.querySelector('.card-profile h3'); + const userName = nameTag ? nameTag.textContent?.trim() || '' : ''; + + // 2. Extraer Foto + const imgTag = doc.querySelector('.userpicture img') as HTMLImageElement; + let userPhotoBase64: string | null = null; + + if (imgTag && imgTag.src) { + try { + // Descargar la imagen y convertir a base64 + const photoRes: HttpResponse = await CapacitorHttp.get({ + url: imgTag.src, + responseType: 'arraybuffer' + }); + userPhotoBase64 = `data:image/jpeg;base64,${photoRes.data}`; + } catch (imgError) { + console.error('Error downloading profile photo:', imgError); + } + } + + return { name: userName, photo: userPhotoBase64 }; + } catch (e) { + console.error('Error fetching user profile:', e); + throw e; + } + } + + private async loginMoodle(dni: string, pass: string): Promise { + // 1. Initial GET to fetch the login page and token + const initialGet: HttpResponse = await CapacitorHttp.get({ + url: this.LOGIN_URL, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + + const parser = new DOMParser(); + const doc = parser.parseFromString(initialGet.data, 'text/html'); + const tokenInput = doc.querySelector('input[name="logintoken"]') as HTMLInputElement; + + if (!tokenInput || !tokenInput.value) { + throw new Error('No se pudo obtener el token de seguridad del campus.'); + } + + const loginToken = tokenInput.value; + + // 2. POST to login + const params = new URLSearchParams(); + params.append('username', dni); + params.append('password', pass); + params.append('logintoken', loginToken); + + const loginPost: HttpResponse = await CapacitorHttp.post({ + url: this.LOGIN_URL, + data: params.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + }); + + // Verify if login was successful + if (loginPost.data && (loginPost.data.includes('loginerrormessage') || loginPost.data.includes('Inicie sesión en su cuenta'))) { + // En algunos casos Moodle no tira loginerrormessage pero vuelve a la pág de login + if (!loginPost.data.includes('user/profile.php') && !loginPost.data.includes('my/')) { + throw new Error('DNI o Clave incorrectos en el Campus.'); + } + } + } + private base64ToUint8Array(base64: string): Uint8Array { const raw = window.atob(base64); const rawLength = raw.length; @@ -137,7 +172,9 @@ export class CredencialService { private async renderPdfToBase64(pdfData: Uint8Array): Promise { // Configure pdf.js to use the standard worker from local assets. - pdfjsLib.GlobalWorkerOptions.workerSrc = 'assets/pdf.worker.min.mjs'; + // Use an absolute URL based on the origin to avoid "Failed to resolve module specifier" on Emulators/Android. + const workerUrl = new URL('assets/pdf.worker.min.mjs', window.location.origin).href; + pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl; const loadingTask = pdfjsLib.getDocument({ data: pdfData }); const pdf = await loadingTask.promise; diff --git a/src/app/services/siu-guarani.service.ts b/src/app/services/siu-guarani.service.ts index 2fc51ae..5112352 100644 --- a/src/app/services/siu-guarani.service.ts +++ b/src/app/services/siu-guarani.service.ts @@ -1,25 +1,15 @@ import { Injectable } from '@angular/core'; import { CapacitorHttp, HttpResponse } from '@capacitor/core'; +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'; async getAcademicHistory(dni: string, pass: string, carreraId: string = '30'): Promise { try { - // 1. LOGIN POST - const loginData = `usuario=${dni}&password=${pass}&login=Ingresar`; - await CapacitorHttp.post({ - url: `${this.BASE_URL}/acceso?auth=form`, - data: loginData, - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } - }); - - // 2. CAMBIO DE CARRERA (Simulando el GET que capturamos) - // Esto es lo que destrabó el cuello de botella - await CapacitorHttp.get({ - url: `${this.BASE_URL}/acceso/cambiar_carrera?id=${carreraId}&op=inicio_alumno`, - }); + await this.loginSiu(dni, pass, carreraId); // 3. OBTENER PLAN DE ESTUDIO const resPlan: HttpResponse = await CapacitorHttp.get({ @@ -27,7 +17,6 @@ export class SiuService { }); // 4. PARSEO DEL JSON EMBEBIDO - // Buscamos el bloque on_arrival que contiene "info_plan" const rawHtml = resPlan.data; const regex = /on_arrival\((\{.*?"id":"info_plan".*?\})\);/s; const match = rawHtml.match(regex); @@ -40,11 +29,73 @@ export class SiuService { throw new Error('No se encontró el bloque de materias'); } catch (error) { - console.error('Error SIU:', error); + console.error('Error SIU Academic History:', error); throw error; } } + async getInscripcionesActivas(dni: string, pass: string, carreraId: string = '30'): Promise { + try { + await this.loginSiu(dni, pass, carreraId); + + // OBTENER PÁGINA DE CURSADA + const resCursada: HttpResponse = await CapacitorHttp.get({ + url: `${this.BASE_URL}/cursada`, + }); + + const rawHtml = resCursada.data; + + // Buscamos el bloque on_arrival que contiene "info_materia" (donde están las inscripciones) + const regex = /on_arrival\((\{.*?"id":"info_materia".*?\})\);/s; + const match = rawHtml.match(regex); + + if (match && match[1]) { + const jsonData = JSON.parse(match[1]); + const contentHtml = jsonData.content; + return this.parseInscripcionesActivasHtml(contentHtml); + } + + return []; // Quizás no tiene inscripciones activas + } catch (error) { + console.error('Error SIU Active Inscriptions:', error); + return []; + } + } + + private async loginSiu(dni: string, pass: string, carreraId: string): Promise { + // 1. LOGIN POST + const loginData = `usuario=${dni}&password=${pass}&login=Ingresar`; + await CapacitorHttp.post({ + url: `${this.BASE_URL}/acceso?auth=form`, + data: loginData, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); + + // 2. CAMBIO DE CARRERA + await CapacitorHttp.get({ + url: `${this.BASE_URL}/acceso/cambiar_carrera?id=${carreraId}&op=inicio_alumno`, + }); + } + + 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')); + + return rows.map(td => { + const text = td.textContent?.trim() || ''; + // Formato esperado: "Nombre Materia - ASIG00125" + const parts = text.split(' - '); + if (parts.length >= 2) { + const codigo = parts[parts.length - 1].trim(); + const nombre = parts.slice(0, -1).join(' - ').trim(); + return { nombre, codigo }; + } + return null; + }).filter(item => item !== null); + } + private parseHtmlSubjects(html: string): any[] { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html');