se ha integrado el login con SIU Guarani y se agrego un manual de usuario en info.
This commit is contained in:
@@ -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 14
|
versionCode 15
|
||||||
versionName "1.2.1"
|
versionName "1.2.2"
|
||||||
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.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aulas-unicaba",
|
"name": "aulas-unicaba",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"author": "Ionic Framework",
|
"author": "Ionic Framework",
|
||||||
"homepage": "https://ionicframework.com/",
|
"homepage": "https://ionicframework.com/",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<ion-header>
|
||||||
|
<ion-toolbar color="primary">
|
||||||
|
<ion-title>Aulas UNICABA</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button (click)="close()">Cerrar</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
<ion-toolbar color="primary">
|
||||||
|
<ion-segment [(ngModel)]="selectedSegment" mode="md">
|
||||||
|
<ion-segment-button value="manual">
|
||||||
|
<ion-label>Manual</ion-label>
|
||||||
|
</ion-segment-button>
|
||||||
|
<ion-segment-button value="privacy">
|
||||||
|
<ion-label>Privacidad</ion-label>
|
||||||
|
</ion-segment-button>
|
||||||
|
</ion-segment>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content class="ion-padding privacy-content">
|
||||||
|
|
||||||
|
<!-- VISTA: MANUAL DE USUARIO -->
|
||||||
|
<div *ngIf="selectedSegment === 'manual'" class="fade-in">
|
||||||
|
<div class="logo-container ion-text-center">
|
||||||
|
<img src="assets/logo_unicaba_mini.png" class="info-logo" alt="UNICABA Logo">
|
||||||
|
<h2>Guía del Estudiante</h2>
|
||||||
|
<p class="subtitle">Aprendé a usar tu Ecosistema Digital</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-body">
|
||||||
|
<section>
|
||||||
|
<h3>1. Mi Cartelera (Horarios y Aulas)</h3>
|
||||||
|
<p>Visualizá todas las materias organizadas por día. Usá la barra de búsqueda para filtrar rápido por nombre, aula o código.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Favoritos:</strong> Toca la estrella ⭐ para activar notificaciones automáticas y recordatorios 30 min antes de clase.</li>
|
||||||
|
<li><strong>Sincronización:</strong> Desliza hacia abajo para traer los últimos datos oficiales.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>2. Mi Carrera (Seguimiento)</h3>
|
||||||
|
<p>Accedé desde el botón inferior para ver tu plan de estudios.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Estados:</strong> Marcá materias como <em>Aprobadas</em> (Fucsia) o <em>En Curso</em> (Celeste).</li>
|
||||||
|
<li><strong>Optativas:</strong> Elegí tus materias directamente desde el selector para completar tu progreso.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>3. Agenda y Exámenes</h3>
|
||||||
|
<p>En el menú de cada materia (`...`) podés agendar <strong>Parciales, Finales o TPs</strong>. Verás insignias de colores avisándote la proximidad.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>4. Credencial Digital QR</h3>
|
||||||
|
<p>Tocá el logo arriba a la izquierda para abrir tu credencial. La app subirá el brillo automáticamente para facilitar el escaneo.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>5. Mantené tus datos seguros</h3>
|
||||||
|
<p>Usa <strong>Perfil > Guardar Respaldo</strong> para exportar un archivo CSV. Si cambias de teléfono, podés restaurarlo desde el mismo lugar.</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- VISTA: PRIVACIDAD -->
|
||||||
|
<div *ngIf="selectedSegment === 'privacy'" class="fade-in">
|
||||||
|
<div class="logo-container ion-text-center">
|
||||||
|
<ion-icon name="shield-checkmark-outline" size="large" color="success"></ion-icon>
|
||||||
|
<h2>Privacidad Zero-Trust</h2>
|
||||||
|
<p class="subtitle">Tus datos son tuyos y de nadie más</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-body">
|
||||||
|
<h3>Sin Intermediarios</h3>
|
||||||
|
<p>Aulas UNICABA no tiene servidores propios. Cada vez que sincronizás la cartelera o tu credencial, la conexión viaja directo de tu teléfono al servidor de la Universidad.</p>
|
||||||
|
|
||||||
|
<h3>Tratamiento de Credenciales</h3>
|
||||||
|
<p>Tus claves de Moodle o SIU Guaraní se guardan de forma encriptada en el almacenamiento seguro de tu dispositivo. Nunca salen de tu teléfono hacia terceros.</p>
|
||||||
|
|
||||||
|
<h3>Analíticas y Rastreos</h3>
|
||||||
|
<p>No usamos Google Analytics ni SDKs de seguimiento. No hay publicidad. Cero telemetría invasiva.</p>
|
||||||
|
|
||||||
|
<div class="footer-info ion-text-center">
|
||||||
|
<p><small>Aulas UNICABA v1.2.2 • Independiente y Offline-First</small></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ion-content>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
.privacy-content {
|
||||||
|
--background: var(--ion-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-segment {
|
||||||
|
--background: var(--ion-color-primary-shade);
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
ion-segment-button {
|
||||||
|
--color: rgba(255,255,255,0.7);
|
||||||
|
--color-checked: white;
|
||||||
|
--indicator-color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
padding: 10px 0 20px;
|
||||||
|
|
||||||
|
.info-logo {
|
||||||
|
height: 80px;
|
||||||
|
background: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ion-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-body {
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
&:last-child { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, li {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ion-color-medium-shade);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin: 10px 0;
|
||||||
|
li { margin-bottom: 8px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-info {
|
||||||
|
margin-top: 30px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { IonicModule, ModalController } from '@ionic/angular';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-info-modal',
|
||||||
|
templateUrl: './info-modal.component.html',
|
||||||
|
styleUrls: ['./info-modal.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, IonicModule, FormsModule]
|
||||||
|
})
|
||||||
|
export class InfoModalComponent {
|
||||||
|
selectedSegment: string = 'manual';
|
||||||
|
|
||||||
|
constructor(private modalCtrl: ModalController) {}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.modalCtrl.dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-78
@@ -1,19 +1,21 @@
|
|||||||
<ion-header [translucent]="true">
|
<ion-header [translucent]="true">
|
||||||
<ion-toolbar color="secondary">
|
<ion-toolbar color="secondary">
|
||||||
<div class="header-content">
|
<div class="header-container-flex">
|
||||||
<img src="assets/logo_unicaba_mini.png" class="header-logo" alt="UNICABA Logo">
|
<div class="header-logo-section" (click)="openInfo()">
|
||||||
<ion-title class="ion-text-center">
|
<img src="assets/logo_unicaba_mini.png" class="header-logo" alt="UNICABA Logo">
|
||||||
UNICABA
|
</div>
|
||||||
</ion-title>
|
|
||||||
<ion-buttons slot="end">
|
<ion-buttons slot="end">
|
||||||
<ion-button *ngIf="viewMode === 'favoritos'" (click)="importarRespaldoManual()" title="Importar Respaldo">
|
<ion-button (click)="openCredencial()" title="Mi Credencial QR">
|
||||||
<ion-icon slot="icon-only" name="cloud-download-outline"></ion-icon>
|
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
<ion-button (click)="goToPerfil()" title="Mi Perfil">
|
<ion-button (click)="goToPerfil()" title="Mi Perfil">
|
||||||
<ion-icon slot="icon-only" name="person-circle-outline"></ion-icon>
|
<div class="user-avatar-small" *ngIf="authState?.userPhoto; else defaultIcon">
|
||||||
</ion-button>
|
<img [src]="authState?.userPhoto" />
|
||||||
<ion-button (click)="isInfoModalOpen = true">
|
</div>
|
||||||
<ion-icon slot="icon-only" name="information-circle-outline"></ion-icon>
|
<ng-template #defaultIcon>
|
||||||
|
<ion-icon slot="icon-only" name="person-circle-outline"></ion-icon>
|
||||||
|
</ng-template>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,69 +39,6 @@
|
|||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
|
|
||||||
<ion-modal [isOpen]="isInfoModalOpen" (didDismiss)="isInfoModalOpen = false">
|
|
||||||
<ng-template>
|
|
||||||
<ion-header>
|
|
||||||
<ion-toolbar color="primary">
|
|
||||||
<ion-title>Información y Privacidad</ion-title>
|
|
||||||
<ion-buttons slot="end">
|
|
||||||
<ion-button (click)="isInfoModalOpen = false">Cerrar</ion-button>
|
|
||||||
</ion-buttons>
|
|
||||||
</ion-toolbar>
|
|
||||||
</ion-header>
|
|
||||||
<ion-content class="ion-padding privacy-content">
|
|
||||||
<h2>¿Qué hace esta aplicación?</h2>
|
|
||||||
<p><strong>Aulas UNICABA</strong> es el visor especializado, independiente y <strong>offline-first</strong> para
|
|
||||||
la cartelera de horarios, campus virtual y seguimiento de tu progreso académico de la Universidad de la Ciudad.
|
|
||||||
Desarrollado pensando en
|
|
||||||
el uso en movimiento, el ahorro de batería y la mala conectividad.</p>
|
|
||||||
|
|
||||||
<h3>Nuevas Características y Funciones</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Cartelera Rápida y Offline:</strong> Consulta de materias organizada por días y cacheada al
|
|
||||||
instante. Muestra datos rápido aunque estés bajo tierra sin red.</li>
|
|
||||||
<li><strong>Mi Carrera y Trackeo:</strong> Ingresá a tu Plan de Estudios interactivo. Usá los Action Sheets
|
|
||||||
nativos para marcar materias como <strong>'Aprobadas'</strong> (Fucsia) o <strong>'En Curso'</strong>
|
|
||||||
(Violeta), y gestioná tus <strong>Materias Optativas</strong> desde el catálogo
|
|
||||||
directamente con un clic.</li>
|
|
||||||
<li><strong>Próxima Clase Inteligente:</strong> Un banner destacado superior en Favoritos te avisa cuándo es y
|
|
||||||
cuánto falta
|
|
||||||
para tu próxima clase, con un reloj regresivo dinámico en vivo.</li>
|
|
||||||
<li><strong>Alertas Automáticas:</strong> Si la universidad cambia repentinamente el número de aula o el link de
|
|
||||||
Meet/Zoom de tus materias, serás notificado. También recibís pre-alertas de 30 minutos antes de cursar.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Agenda de Hitos Académicos</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Accedé rápidamente para agendar <strong>Parciales / Trabajos Prácticos / Finales</strong> desde cualquier
|
|
||||||
materia para llevar un control estricto.</li>
|
|
||||||
<li>El robusto sistema de calendario nativo "Wheels" evitará borrados accidentales de tus fechas importantes.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Credencial Inmediata y Exportación</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Credencial Digital:</strong> Tocando el logo superior izquierdo obtenés tu QR offline y se sube el
|
|
||||||
brillo de tu pantalla al máximo de forma automática para facilitar la lectura.</li>
|
|
||||||
<li><strong>Respaldo Portable Seguro:</strong> Apretando la "Nubecita" o "Disquete" podés emitir un archivo de
|
|
||||||
backup (JSON seguro) hacia la memoria o nube de tu teléfono, para que restaures de inmediato toda la
|
|
||||||
configuración si mudás a otro equipo.</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3>Privacidad Zero-Trust</h3>
|
|
||||||
<p><strong>Sin Servidores Intermediarios:</strong> No dependemos de bases de datos centralizadas, ni recopilamos
|
|
||||||
credenciales. Cada sincronización viaja directo desde Aulas UNICABA hasta el servidor del SIU/Google localmente.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="ion-text-center" style="margin-top: 30px;"><small>Tu ecosistema digital universitario, diseñado por y
|
|
||||||
para estudiantes.</small></p>
|
|
||||||
|
|
||||||
<p class="ion-text-center" style="margin-top: 30px;"><small><strong>Versión 1.2.1</strong></small></p>
|
|
||||||
|
|
||||||
</ion-content>
|
|
||||||
</ng-template>
|
|
||||||
</ion-modal>
|
|
||||||
|
|
||||||
<ion-content [fullscreen]="true" color="light" (swipe)="onSwipe($event)">
|
<ion-content [fullscreen]="true" color="light" (swipe)="onSwipe($event)">
|
||||||
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
|
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
|
||||||
<ion-refresher-content pullingText="Desliza para actualizar" refreshingSpinner="crescent"
|
<ion-refresher-content pullingText="Desliza para actualizar" refreshingSpinner="crescent"
|
||||||
@@ -209,10 +148,6 @@
|
|||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
<span class="selector-title" style="margin: 0;">Plan de Estudios / Recorrido</span>
|
<span class="selector-title" style="margin: 0;">Plan de Estudios / Recorrido</span>
|
||||||
<div style="display: flex; gap: 8px;">
|
<div style="display: flex; gap: 8px;">
|
||||||
<ion-button fill="outline" color="primary" size="small" (click)="forzarRespaldo()" style="margin: 0;"
|
|
||||||
title="Guardar Manualmente" class="header-btn">
|
|
||||||
<ion-icon name="save-outline" slot="icon-only"></ion-icon>
|
|
||||||
</ion-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="selector-desc">Selecciona tu carrera para guardar tu avance y ver el recorrido sugerido, toca materias
|
<p class="selector-desc">Selecciona tu carrera para guardar tu avance y ver el recorrido sugerido, toca materias
|
||||||
|
|||||||
@@ -29,20 +29,44 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Header styles
|
// Header styles
|
||||||
.header-content {
|
.header-container-flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 10px;
|
justify-content: space-between;
|
||||||
|
padding: 0 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 0;
|
||||||
|
|
||||||
.header-logo {
|
.header-logo {
|
||||||
height: 36px;
|
height: 38px;
|
||||||
width: auto;
|
background: white;
|
||||||
background-color: white;
|
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 5px;
|
border-radius: 8px;
|
||||||
position: absolute;
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
left: 10px;
|
}
|
||||||
z-index: 10;
|
}
|
||||||
|
|
||||||
|
.user-avatar-small {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-137
@@ -1,5 +1,6 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { ToastController, ModalController, AlertController, ActionSheetController, NavController } from '@ionic/angular';
|
import { ToastController, ModalController, AlertController, ActionSheetController, NavController } from '@ionic/angular';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
import { DataService, ClaseHorario, EventoAcademico } from '../services/data';
|
import { DataService, ClaseHorario, EventoAcademico } from '../services/data';
|
||||||
import { Preferences } from '@capacitor/preferences';
|
import { Preferences } from '@capacitor/preferences';
|
||||||
@@ -13,8 +14,9 @@ import { LocalStorageService } from '../services/local-storage.service';
|
|||||||
import { EventosService } from '../services/eventos.service';
|
import { EventosService } from '../services/eventos.service';
|
||||||
import { EventoModalComponent } from '../components/evento-modal/evento-modal.component';
|
import { EventoModalComponent } from '../components/evento-modal/evento-modal.component';
|
||||||
import { AgendaModalComponent } from '../components/agenda-modal/agenda-modal.component';
|
import { AgendaModalComponent } from '../components/agenda-modal/agenda-modal.component';
|
||||||
|
import { InfoModalComponent } from '../components/info-modal/info-modal.component';
|
||||||
import { SiuService } from '../services/siu-guarani.service';
|
import { SiuService } from '../services/siu-guarani.service';
|
||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService, AuthState } from '../services/auth.service';
|
||||||
|
|
||||||
|
|
||||||
export interface ProximaClaseInfo {
|
export interface ProximaClaseInfo {
|
||||||
@@ -44,6 +46,9 @@ export class HomePage implements OnInit {
|
|||||||
errorMsg: string = '';
|
errorMsg: string = '';
|
||||||
isInfoModalOpen: boolean = false;
|
isInfoModalOpen: boolean = false;
|
||||||
|
|
||||||
|
authState: AuthState | null = null;
|
||||||
|
private authSub: Subscription | null = null;
|
||||||
|
|
||||||
proximaClase: ProximaClaseInfo | null = null;
|
proximaClase: ProximaClaseInfo | null = null;
|
||||||
private countdownTimer: any = null;
|
private countdownTimer: any = null;
|
||||||
|
|
||||||
@@ -95,6 +100,10 @@ export class HomePage implements OnInit {
|
|||||||
|
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
|
this.authSub = this.authService.authState$.subscribe(state => {
|
||||||
|
this.authState = state;
|
||||||
|
});
|
||||||
|
|
||||||
this.route.queryParams.subscribe(params => {
|
this.route.queryParams.subscribe(params => {
|
||||||
this.highlightedCodigo = params['highlight'] || null;
|
this.highlightedCodigo = params['highlight'] || null;
|
||||||
if (this.highlightedCodigo) {
|
if (this.highlightedCodigo) {
|
||||||
@@ -118,10 +127,8 @@ export class HomePage implements OnInit {
|
|||||||
this.favoritos = userData.favoritos || [];
|
this.favoritos = userData.favoritos || [];
|
||||||
this.optativasSeleccionadas = userData.optativas || [];
|
this.optativasSeleccionadas = userData.optativas || [];
|
||||||
|
|
||||||
// Crucial: Recargar los eventos al objeto local
|
|
||||||
this.eventos = await this.eventosService.loadEventos();
|
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) {
|
if (this.siuMaterias.length > 0) {
|
||||||
await this.autoCompletarDesdeSiu();
|
await this.autoCompletarDesdeSiu();
|
||||||
}
|
}
|
||||||
@@ -173,8 +180,6 @@ export class HomePage implements OnInit {
|
|||||||
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 {
|
|
||||||
await this.promptForRestore();
|
|
||||||
}
|
}
|
||||||
this.eventos = await this.eventosService.loadEventos();
|
this.eventos = await this.eventosService.loadEventos();
|
||||||
}
|
}
|
||||||
@@ -191,6 +196,13 @@ export class HomePage implements OnInit {
|
|||||||
|
|
||||||
// 3. Fetch from Server
|
// 3. Fetch from Server
|
||||||
this.syncData();
|
this.syncData();
|
||||||
|
|
||||||
|
// 4. Mostrar Info Modal automáticamente solo la PRIMERA VEZ (Onboarding)
|
||||||
|
const infoSeen = await Preferences.get({ key: 'info_modal_v1_seen' });
|
||||||
|
if (!infoSeen.value) {
|
||||||
|
setTimeout(() => this.openInfo(), 1000); // Pequeño delay para que la UI se asiente
|
||||||
|
await Preferences.set({ key: 'info_modal_v1_seen', value: 'true' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncData(event?: any) {
|
syncData(event?: any) {
|
||||||
@@ -245,46 +257,6 @@ export class HomePage implements OnInit {
|
|||||||
this.syncData(event);
|
this.syncData(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
async promptForRestore() {
|
|
||||||
const alert = await this.alertCtrl.create({
|
|
||||||
header: '¡Datos no encontrados!',
|
|
||||||
message: 'No encontramos tu configuración de favoritos. Si tienes un archivo CSV de respaldo (aulas_unicaba_datos.csv) en Google Drive, iCloud, o en las descargas de tu dispositivo, puedes restaurarlo manualmente.',
|
|
||||||
buttons: [
|
|
||||||
{ text: 'Empezar de cero', role: 'cancel' },
|
|
||||||
{
|
|
||||||
text: 'Buscar Respaldo',
|
|
||||||
handler: async () => {
|
|
||||||
const imported = await this.localStorageService.importBackup();
|
|
||||||
if (imported && (imported.favoritos.length > 0 || imported.eventos.length > 0 || imported.aprobadas?.length > 0 || imported.optativas?.length > 0 || imported.encurso?.length > 0)) {
|
|
||||||
this.favoritos = imported.favoritos;
|
|
||||||
this.aprobadas = imported.aprobadas || [];
|
|
||||||
this.encurso = imported.encurso || [];
|
|
||||||
this.optativasSeleccionadas = imported.optativas || [];
|
|
||||||
this.siuMaterias = imported.siuMaterias || [];
|
|
||||||
this.siuLastSync = imported.siuLastSync || null;
|
|
||||||
|
|
||||||
// Sincronizar eventos cargados desde el CSV con el servicio de eventos
|
|
||||||
for (const ev of imported.eventos) {
|
|
||||||
await this.eventosService.saveEvento(ev);
|
|
||||||
}
|
|
||||||
this.eventos = await this.eventosService.loadEventos();
|
|
||||||
|
|
||||||
this.viewMode = 'favoritos';
|
|
||||||
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
|
|
||||||
this.filterHorarios();
|
|
||||||
|
|
||||||
const toast = await this.toastCtrl.create({ message: 'Respaldo restaurado con éxito.', duration: 2500, color: 'success' });
|
|
||||||
toast.present();
|
|
||||||
} else {
|
|
||||||
const toast = await this.toastCtrl.create({ message: 'No se pudo leer el archivo o está vacío.', duration: 2500, color: 'danger' });
|
|
||||||
toast.present();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
await alert.present();
|
|
||||||
}
|
|
||||||
|
|
||||||
filterHorarios() {
|
filterHorarios() {
|
||||||
let baseList = this.horarios;
|
let baseList = this.horarios;
|
||||||
@@ -581,6 +553,20 @@ export class HomePage implements OnInit {
|
|||||||
window.open(url, '_system');
|
window.open(url, '_system');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openInfo() {
|
||||||
|
const modal = await this.modalCtrl.create({
|
||||||
|
component: InfoModalComponent
|
||||||
|
});
|
||||||
|
await modal.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
async openCredencial() {
|
||||||
|
const modal = await this.modalCtrl.create({
|
||||||
|
component: CredencialModalComponent
|
||||||
|
});
|
||||||
|
await modal.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- Hitos Académicos (Agenda) ---
|
// --- Hitos Académicos (Agenda) ---
|
||||||
|
|
||||||
@@ -920,97 +906,6 @@ export class HomePage implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async forzarRespaldo() {
|
|
||||||
this.guardarProgreso();
|
|
||||||
const data = {
|
|
||||||
favoritos: this.favoritos,
|
|
||||||
eventos: this.eventos,
|
|
||||||
aprobadas: this.aprobadas,
|
|
||||||
optativas: this.optativasSeleccionadas,
|
|
||||||
encurso: this.encurso,
|
|
||||||
siuMaterias: this.siuMaterias,
|
|
||||||
siuLastSync: this.siuLastSync
|
|
||||||
};
|
|
||||||
|
|
||||||
const actionSheet = await this.actionSheetCtrl.create({
|
|
||||||
header: 'Guardar Respaldo',
|
|
||||||
subHeader: '¿A dónde querés exportar tus materias guardadas?',
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
text: 'Compartir (Drive, WhatsApp, Mail...)',
|
|
||||||
icon: 'share-social-outline',
|
|
||||||
handler: async () => {
|
|
||||||
await this.ejecutarExportacion(data, false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Descargar Offline (A la carpeta Descargas)',
|
|
||||||
icon: 'download-outline',
|
|
||||||
handler: async () => {
|
|
||||||
await this.ejecutarExportacion(data, true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Cancelar',
|
|
||||||
icon: 'close',
|
|
||||||
role: 'cancel'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
await actionSheet.present();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ejecutarExportacion(data: any, localMode: boolean) {
|
|
||||||
const ok = await this.localStorageService.exportBackup(data, localMode);
|
|
||||||
if (ok) {
|
|
||||||
const toast = await this.toastCtrl.create({
|
|
||||||
message: localMode ? 'Archivo descargado en tu teléfono.' : 'Respaldo emitido correctamente.',
|
|
||||||
duration: 3500,
|
|
||||||
color: 'success',
|
|
||||||
icon: 'save'
|
|
||||||
});
|
|
||||||
toast.present();
|
|
||||||
await Haptics.impact({ style: ImpactStyle.Heavy }).catch(() => { });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async importarRespaldoManual() {
|
|
||||||
const imported = await this.localStorageService.importBackup();
|
|
||||||
if (imported && (imported.favoritos.length > 0 || imported.eventos.length > 0 || imported.aprobadas?.length > 0 || imported.optativas?.length > 0 || imported.encurso?.length > 0)) {
|
|
||||||
this.favoritos = imported.favoritos;
|
|
||||||
this.aprobadas = imported.aprobadas || [];
|
|
||||||
this.encurso = imported.encurso || [];
|
|
||||||
this.optativasSeleccionadas = imported.optativas || [];
|
|
||||||
this.siuMaterias = imported.siuMaterias || [];
|
|
||||||
this.siuLastSync = imported.siuLastSync || null;
|
|
||||||
|
|
||||||
for (const ev of imported.eventos) {
|
|
||||||
await this.eventosService.saveEvento(ev);
|
|
||||||
}
|
|
||||||
this.eventos = await this.eventosService.loadEventos();
|
|
||||||
|
|
||||||
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
|
|
||||||
this.guardarProgreso();
|
|
||||||
this.filterHorarios();
|
|
||||||
|
|
||||||
const toast = await this.toastCtrl.create({
|
|
||||||
message: '¡Respaldo importado correctamente!',
|
|
||||||
duration: 3000,
|
|
||||||
color: 'success',
|
|
||||||
icon: 'checkmark-circle'
|
|
||||||
});
|
|
||||||
toast.present();
|
|
||||||
await Haptics.impact({ style: ImpactStyle.Heavy }).catch(() => { });
|
|
||||||
} else {
|
|
||||||
const toast = await this.toastCtrl.create({
|
|
||||||
message: 'No se procesó ningún archivo válido.',
|
|
||||||
duration: 2500,
|
|
||||||
color: 'warning',
|
|
||||||
icon: 'alert-circle'
|
|
||||||
});
|
|
||||||
toast.present();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
goToPerfil() {
|
goToPerfil() {
|
||||||
this.navCtrl.navigateForward('/perfil');
|
this.navCtrl.navigateForward('/perfil');
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
<ion-back-button defaultHref="/home"></ion-back-button>
|
<ion-back-button defaultHref="/home"></ion-back-button>
|
||||||
</ion-buttons>
|
</ion-buttons>
|
||||||
<ion-title>Configuración de Perfil</ion-title>
|
<ion-title>Configuración de Perfil</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button (click)="openInfo()">
|
||||||
|
<ion-icon slot="icon-only" name="information-circle-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
</ion-toolbar>
|
</ion-toolbar>
|
||||||
</ion-header>
|
</ion-header>
|
||||||
|
|
||||||
@@ -34,7 +39,8 @@
|
|||||||
</ion-card-header>
|
</ion-card-header>
|
||||||
|
|
||||||
<ion-card-content *ngIf="!authState?.siuConnected">
|
<ion-card-content *ngIf="!authState?.siuConnected">
|
||||||
<p class="auth-desc">Conectá con el SIU para traer tu historial y que la app auto-favoritee tus materias en curso.</p>
|
<p class="auth-desc">Conectá con el SIU para traer tu historial y que la app auto-favoritee tus materias en
|
||||||
|
curso.</p>
|
||||||
<ion-list lines="inset" class="auth-form">
|
<ion-list lines="inset" class="auth-form">
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-icon name="person-outline" slot="start" color="medium"></ion-icon>
|
<ion-icon name="person-outline" slot="start" color="medium"></ion-icon>
|
||||||
@@ -42,7 +48,8 @@
|
|||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-icon name="lock-closed-outline" slot="start" color="medium"></ion-icon>
|
<ion-icon name="lock-closed-outline" slot="start" color="medium"></ion-icon>
|
||||||
<ion-input [type]="showSiuPass ? 'text' : 'password'" placeholder="Contraseña de SIU" [(ngModel)]="siuPass"></ion-input>
|
<ion-input [type]="showSiuPass ? 'text' : 'password'" placeholder="Contraseña de SIU"
|
||||||
|
[(ngModel)]="siuPass"></ion-input>
|
||||||
<ion-button slot="end" fill="clear" (click)="showSiuPass = !showSiuPass" color="medium">
|
<ion-button slot="end" fill="clear" (click)="showSiuPass = !showSiuPass" color="medium">
|
||||||
<ion-icon [name]="showSiuPass ? 'eye-off-outline' : 'eye-outline'"></ion-icon>
|
<ion-icon [name]="showSiuPass ? 'eye-off-outline' : 'eye-outline'"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
@@ -83,7 +90,8 @@
|
|||||||
</ion-card-header>
|
</ion-card-header>
|
||||||
|
|
||||||
<ion-card-content *ngIf="!authState?.moodleConnected">
|
<ion-card-content *ngIf="!authState?.moodleConnected">
|
||||||
<p class="auth-desc">Vinculá tu cuenta del campus para generar tu credencial digital offline y habilitar alertas de actividades.</p>
|
<p class="auth-desc">Vinculá tu cuenta del campus para generar tu credencial digital offline y habilitar alertas
|
||||||
|
de actividades.</p>
|
||||||
<ion-list lines="inset" class="auth-form">
|
<ion-list lines="inset" class="auth-form">
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-icon name="person-outline" slot="start" color="medium"></ion-icon>
|
<ion-icon name="person-outline" slot="start" color="medium"></ion-icon>
|
||||||
@@ -91,7 +99,8 @@
|
|||||||
</ion-item>
|
</ion-item>
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-icon name="lock-closed-outline" slot="start" color="medium"></ion-icon>
|
<ion-icon name="lock-closed-outline" slot="start" color="medium"></ion-icon>
|
||||||
<ion-input [type]="showMoodlePass ? 'text' : 'password'" placeholder="Contraseña de Campus" [(ngModel)]="moodlePass"></ion-input>
|
<ion-input [type]="showMoodlePass ? 'text' : 'password'" placeholder="Contraseña de Campus"
|
||||||
|
[(ngModel)]="moodlePass"></ion-input>
|
||||||
<ion-button slot="end" fill="clear" (click)="showMoodlePass = !showMoodlePass" color="medium">
|
<ion-button slot="end" fill="clear" (click)="showMoodlePass = !showMoodlePass" color="medium">
|
||||||
<ion-icon [name]="showMoodlePass ? 'eye-off-outline' : 'eye-outline'"></ion-icon>
|
<ion-icon [name]="showMoodlePass ? 'eye-off-outline' : 'eye-outline'"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
@@ -119,9 +128,50 @@
|
|||||||
</ion-card-content>
|
</ion-card-content>
|
||||||
</ion-card>
|
</ion-card>
|
||||||
|
|
||||||
|
<!-- Sección de Credencial Digital (Solo si está vinculado) -->
|
||||||
|
<ion-card class="auth-card credencial-card" *ngIf="authState?.moodleConnected" (click)="openCredencial()">
|
||||||
|
<ion-card-header>
|
||||||
|
<div class="card-title-row">
|
||||||
|
<ion-icon name="qr-code" color="primary" class="service-icon"></ion-icon>
|
||||||
|
<ion-card-title>Mi Credencial Digital</ion-card-title>
|
||||||
|
<ion-icon name="chevron-forward" color="medium"></ion-icon>
|
||||||
|
</div>
|
||||||
|
<ion-card-subtitle>Acceso rápido con QR y modo sin conexión</ion-card-subtitle>
|
||||||
|
</ion-card-header>
|
||||||
|
<ion-card-content>
|
||||||
|
<p class="auth-desc">Toca para abrir tu credencial. El brillo se ajustará automáticamente.</p>
|
||||||
|
</ion-card-content>
|
||||||
|
</ion-card>
|
||||||
|
|
||||||
|
<!-- Sección de Respaldo y Datos -->
|
||||||
|
<ion-card class="auth-card backup-card">
|
||||||
|
<ion-card-header>
|
||||||
|
<div class="card-title-row">
|
||||||
|
<ion-icon name="cloud-upload" color="tertiary" class="service-icon"></ion-icon>
|
||||||
|
<ion-card-title>Datos y Respaldo</ion-card-title>
|
||||||
|
</div>
|
||||||
|
<ion-card-subtitle>Asegurá tu progreso académico</ion-card-subtitle>
|
||||||
|
</ion-card-header>
|
||||||
|
<ion-card-content>
|
||||||
|
<p class="auth-desc">Exportá tus materias favoritas y aprobadas para restaurarlas en otro dispositivo.</p>
|
||||||
|
<div class="backup-buttons">
|
||||||
|
<ion-button expand="block" fill="outline" (click)="forzarRespaldo()">
|
||||||
|
<ion-icon name="save-outline" slot="start"></ion-icon>
|
||||||
|
Guardar Respaldo
|
||||||
|
</ion-button>
|
||||||
|
<ion-button expand="block" fill="outline" color="secondary" (click)="importarRespaldoManual()">
|
||||||
|
<ion-icon name="cloud-download-outline" slot="start"></ion-icon>
|
||||||
|
Restaurar Datos
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</ion-card-content>
|
||||||
|
</ion-card>
|
||||||
|
|
||||||
|
|
||||||
<div class="security-info ion-padding">
|
<div class="security-info ion-padding">
|
||||||
<ion-icon name="shield-checkmark" color="medium"></ion-icon>
|
<ion-icon name="lock-closed" color="medium"></ion-icon>
|
||||||
<p>Tus credenciales se almacenan de forma segura y local en tu dispositivo. No son enviadas a servidores externos ajenos a la universidad.</p>
|
<p>Tus credenciales y datos se almacenan de forma segura y local en el almacenamiento persistente de tu teléfono.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
@@ -165,6 +165,71 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.credencial-card {
|
||||||
|
background: linear-gradient(to right, rgba(var(--ion-color-primary-rgb), 0.05), transparent);
|
||||||
|
border-left: 6px solid var(--ion-color-primary) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-card {
|
||||||
|
.backup-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
ion-button {
|
||||||
|
--border-radius: 12px;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--ion-color-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-info {
|
||||||
|
margin-top: 20px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
padding-top: 15px;
|
||||||
|
color: var(--ion-color-medium);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.error-text {
|
.error-text {
|
||||||
color: var(--ion-color-danger);
|
color: var(--ion-color-danger);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -180,15 +245,13 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|
||||||
ion-icon {
|
ion-icon {
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
max-width: 250px;
|
max-width: 280px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { ToastController, LoadingController, AlertController, NavController } from '@ionic/angular';
|
import { ToastController, LoadingController, AlertController, NavController, ActionSheetController, ModalController } from '@ionic/angular';
|
||||||
import { AuthService, AuthState } from '../services/auth.service';
|
import { AuthService, AuthState } from '../services/auth.service';
|
||||||
import { SiuService } from '../services/siu-guarani.service';
|
import { SiuService } from '../services/siu-guarani.service';
|
||||||
import { CredencialService } from '../services/credencial.service';
|
import { CredencialService } from '../services/credencial.service';
|
||||||
import { LocalStorageService, AppData } from '../services/local-storage.service';
|
import { LocalStorageService, AppData } from '../services/local-storage.service';
|
||||||
|
import { EventosService } from '../services/eventos.service';
|
||||||
|
import { CredencialModalComponent } from '../components/credencial-modal/credencial-modal.component';
|
||||||
|
import { InfoModalComponent } from '../components/info-modal/info-modal.component';
|
||||||
|
import { Haptics, ImpactStyle } from '@capacitor/haptics';
|
||||||
|
import { Preferences } from '@capacitor/preferences';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -32,9 +37,12 @@ export class PerfilPage implements OnInit, OnDestroy {
|
|||||||
private siuService: SiuService,
|
private siuService: SiuService,
|
||||||
private credencialService: CredencialService,
|
private credencialService: CredencialService,
|
||||||
private localStorageService: LocalStorageService,
|
private localStorageService: LocalStorageService,
|
||||||
|
private eventosService: EventosService,
|
||||||
private toastCtrl: ToastController,
|
private toastCtrl: ToastController,
|
||||||
private loadingCtrl: LoadingController,
|
private loadingCtrl: LoadingController,
|
||||||
private alertCtrl: AlertController,
|
private alertCtrl: AlertController,
|
||||||
|
private actionSheetCtrl: ActionSheetController,
|
||||||
|
private modalCtrl: ModalController,
|
||||||
private navCtrl: NavController
|
private navCtrl: NavController
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@@ -115,14 +123,17 @@ export class PerfilPage implements OnInit, OnDestroy {
|
|||||||
await loader.present();
|
await loader.present();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Validar y generar credencial (descarga PDF)
|
// setupMoodleAccount hace UNA sola autenticación y luego descarga la credencial
|
||||||
await this.credencialService.generateCredential(this.moodleDni, this.moodlePass);
|
// y el perfil usando la misma sesión. Evita el bug de doble login donde la segunda
|
||||||
|
// llamada a loginMoodle() encuentra al usuario ya autenticado y no encuentra el
|
||||||
|
// logintoken en la página → "No se pudo obtener el token de seguridad del campus."
|
||||||
|
const result = await this.credencialService.setupMoodleAccount(
|
||||||
|
this.moodleDni,
|
||||||
|
this.moodlePass,
|
||||||
|
(status) => loader.message = status // actualiza el mensaje del loader en tiempo real
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Obtener Perfil (Nombre y Foto)
|
await this.authService.saveUserProfile(result.profile.name, result.profile.photo);
|
||||||
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);
|
await this.authService.saveMoodleCredentials(this.moodleDni, this.moodlePass);
|
||||||
|
|
||||||
this.showToast('¡Perfil sincronizado y Campus conectado!', 'success');
|
this.showToast('¡Perfil sincronizado y Campus conectado!', 'success');
|
||||||
@@ -136,6 +147,7 @@ export class PerfilPage implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async logoutMoodle() {
|
async logoutMoodle() {
|
||||||
const alert = await this.alertCtrl.create({
|
const alert = await this.alertCtrl.create({
|
||||||
header: 'Cerrar Sesión en Campus',
|
header: 'Cerrar Sesión en Campus',
|
||||||
@@ -156,6 +168,121 @@ export class PerfilPage implements OnInit, OnDestroy {
|
|||||||
await alert.present();
|
await alert.present();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- NUEVAS FUNCIONES CONSOLIDADAS ---
|
||||||
|
|
||||||
|
async openInfo() {
|
||||||
|
const modal = await this.modalCtrl.create({
|
||||||
|
component: InfoModalComponent
|
||||||
|
});
|
||||||
|
await modal.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
async openCredencial() {
|
||||||
|
const modal = await this.modalCtrl.create({
|
||||||
|
component: CredencialModalComponent
|
||||||
|
});
|
||||||
|
await modal.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
async forzarRespaldo() {
|
||||||
|
// Obtenemos los datos actuales desde el service para asegurar que el backup sea completo
|
||||||
|
const data = await this.localStorageService.loadData() || this.getDefaultData();
|
||||||
|
|
||||||
|
const actionSheet = await this.actionSheetCtrl.create({
|
||||||
|
header: 'Guardar Respaldo',
|
||||||
|
subHeader: '¿A dónde querés exportar tus materias guardadas?',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Compartir (Drive, WhatsApp, Mail...)',
|
||||||
|
icon: 'share-social-outline',
|
||||||
|
handler: async () => {
|
||||||
|
await this.ejecutarExportacion(data, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Descargar Offline (A la carpeta Descargas)',
|
||||||
|
icon: 'download-outline',
|
||||||
|
handler: async () => {
|
||||||
|
await this.ejecutarExportacion(data, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Cancelar',
|
||||||
|
icon: 'close',
|
||||||
|
role: 'cancel'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await actionSheet.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ejecutarExportacion(data: any, localMode: boolean) {
|
||||||
|
const ok = await this.localStorageService.exportBackup(data, localMode);
|
||||||
|
if (ok) {
|
||||||
|
const toast = await this.toastCtrl.create({
|
||||||
|
message: localMode ? 'Archivo descargado en tu teléfono.' : 'Respaldo emitido correctamente.',
|
||||||
|
duration: 3500,
|
||||||
|
color: 'success',
|
||||||
|
icon: 'save'
|
||||||
|
});
|
||||||
|
toast.present();
|
||||||
|
await Haptics.impact({ style: ImpactStyle.Heavy }).catch(() => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async importarRespaldoManual() {
|
||||||
|
const imported = await this.localStorageService.importBackup();
|
||||||
|
if (imported && (imported.favoritos?.length > 0 || imported.eventos?.length > 0 || imported.aprobadas?.length > 0)) {
|
||||||
|
|
||||||
|
// Guardamos los eventos uno por uno (el service se encarga de la persistencia de eventos)
|
||||||
|
if (imported.eventos) {
|
||||||
|
for (const ev of imported.eventos) {
|
||||||
|
await this.eventosService.saveEvento(ev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guardamos el resto de los datos en el LocalStorageService
|
||||||
|
// Nota: No sobreescribimos TODO el objeto para no borrar fotos de perfil si las hubiera,
|
||||||
|
// pero sí actualizamos los datos académicos.
|
||||||
|
const currentData = await this.localStorageService.loadData() || this.getDefaultData();
|
||||||
|
const newData = {
|
||||||
|
...currentData,
|
||||||
|
favoritos: imported.favoritos || currentData.favoritos,
|
||||||
|
aprobadas: imported.aprobadas || currentData.aprobadas,
|
||||||
|
encurso: imported.encurso || currentData.encurso,
|
||||||
|
optativas: imported.optativas || currentData.optativas,
|
||||||
|
siuMaterias: imported.siuMaterias || currentData.siuMaterias,
|
||||||
|
siuLastSync: imported.siuLastSync || currentData.siuLastSync
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.localStorageService.saveData(newData);
|
||||||
|
|
||||||
|
// Sincronizar favoritos con Capacitor Preferences para que el resto de la app los vea
|
||||||
|
if (imported.favoritos) {
|
||||||
|
await Preferences.set({ key: 'favoritos', value: JSON.stringify(imported.favoritos) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = await this.toastCtrl.create({
|
||||||
|
message: '¡Respaldo importado correctamente!',
|
||||||
|
duration: 3000,
|
||||||
|
color: 'success',
|
||||||
|
icon: 'checkmark-circle'
|
||||||
|
});
|
||||||
|
toast.present();
|
||||||
|
await Haptics.impact({ style: ImpactStyle.Heavy }).catch(() => { });
|
||||||
|
|
||||||
|
// Opcional: Podríamos forzar un refresh del authState si fuera necesario
|
||||||
|
} else {
|
||||||
|
const toast = await this.toastCtrl.create({
|
||||||
|
message: 'No se procesó ningún archivo válido.',
|
||||||
|
duration: 2500,
|
||||||
|
color: 'warning',
|
||||||
|
icon: 'alert-circle'
|
||||||
|
});
|
||||||
|
toast.present();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
goBack() {
|
goBack() {
|
||||||
this.navCtrl.back();
|
this.navCtrl.back();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,6 @@ import { AuthService } from './auth.service';
|
|||||||
import { Preferences } from '@capacitor/preferences';
|
import { Preferences } from '@capacitor/preferences';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
|
||||||
// Set up PDF.js worker. If not set, it falls back to a fake worker, which is fine for small files.
|
|
||||||
// For best performance without messing with angular.json assets, we can let it use the fake worker
|
|
||||||
// or set it to null. We'll let it fallback automatically.
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
@@ -30,25 +26,24 @@ export class CredencialService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes the full flow: Login -> Scrape Token -> Submit Login -> Download PDF -> Render Base64
|
* Executes the full flow: Login -> Download PDF -> Render Base64
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Executes the full flow: Login -> Scrape Token -> Submit Login -> Download PDF -> Render Base64
|
|
||||||
*/
|
*/
|
||||||
async generateCredential(dni: string, pass: string, onStatusChange?: (status: string) => void): Promise<string> {
|
async generateCredential(dni: string, pass: string, onStatusChange?: (status: string) => void): Promise<string> {
|
||||||
try {
|
try {
|
||||||
if (onStatusChange) onStatusChange('Conectando con el campus...');
|
if (onStatusChange) onStatusChange('Conectando con el campus...');
|
||||||
|
|
||||||
await this.loginMoodle(dni, pass);
|
// Login and get the authenticated session cookie
|
||||||
|
const sessionCookie = await this.loginMoodle(dni, pass);
|
||||||
|
|
||||||
if (onStatusChange) onStatusChange('Descargando PDF...');
|
if (onStatusChange) onStatusChange('Descargando PDF...');
|
||||||
|
|
||||||
// 3. Download the PDF
|
// Download the PDF, forwarding the authenticated session cookie
|
||||||
const pdfRes: HttpResponse = await CapacitorHttp.get({
|
const pdfRes: HttpResponse = await CapacitorHttp.get({
|
||||||
url: this.CREDENTIAL_URL,
|
url: this.CREDENTIAL_URL,
|
||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
...(sessionCookie ? { 'Cookie': sessionCookie } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,10 +57,10 @@ export class CredencialService {
|
|||||||
|
|
||||||
if (onStatusChange) onStatusChange('Generando imagen de credencial (esto puede tardar unos segundos)...');
|
if (onStatusChange) onStatusChange('Generando imagen de credencial (esto puede tardar unos segundos)...');
|
||||||
|
|
||||||
// 4. Render PDF to Base64 Image using pdf.js
|
// Render PDF to Base64 Image using pdf.js
|
||||||
const imgBase64 = await this.renderPdfToBase64(pdfData);
|
const imgBase64 = await this.renderPdfToBase64(pdfData);
|
||||||
|
|
||||||
// Save to cache securely
|
// Save to cache
|
||||||
await Preferences.set({ key: this.PREF_IMAGE, value: imgBase64 });
|
await Preferences.set({ key: this.PREF_IMAGE, value: imgBase64 });
|
||||||
|
|
||||||
if (onStatusChange) onStatusChange('¡Listo!');
|
if (onStatusChange) onStatusChange('¡Listo!');
|
||||||
@@ -75,89 +70,231 @@ export class CredencialService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined setup: ONE login -> download credential PDF + fetch user profile.
|
||||||
|
* Use this instead of calling generateCredential() + getUserProfile() separately,
|
||||||
|
* which would trigger two separate logins. The second login fails because
|
||||||
|
* Moodle redirects the GET /login to the dashboard (already logged in),
|
||||||
|
* so no logintoken is found.
|
||||||
|
*/
|
||||||
|
async setupMoodleAccount(
|
||||||
|
dni: string,
|
||||||
|
pass: string,
|
||||||
|
onStatusChange?: (status: string) => void
|
||||||
|
): Promise<{ credentialImage: string; profile: { name: string; photo: string | null } }> {
|
||||||
|
if (onStatusChange) onStatusChange('Conectando con el campus...');
|
||||||
|
|
||||||
|
// ONE login for everything
|
||||||
|
const sessionCookie = await this.loginMoodle(dni, pass);
|
||||||
|
|
||||||
|
// 1. Download credential PDF
|
||||||
|
if (onStatusChange) onStatusChange('Descargando credencial...');
|
||||||
|
const pdfRes: HttpResponse = await CapacitorHttp.get({
|
||||||
|
url: this.CREDENTIAL_URL,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
...(sessionCookie ? { 'Cookie': sessionCookie } : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = pdfRes.headers && (pdfRes.headers['Content-Type'] || pdfRes.headers['content-type']);
|
||||||
|
if (!contentType || !contentType.includes('application/pdf')) {
|
||||||
|
throw new Error(`El campus no devolvió un PDF. Recibido: ${contentType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onStatusChange) onStatusChange('Generando imagen de credencial...');
|
||||||
|
const pdfData = this.base64ToUint8Array(pdfRes.data);
|
||||||
|
const imgBase64 = await this.renderPdfToBase64(pdfData);
|
||||||
|
await Preferences.set({ key: this.PREF_IMAGE, value: imgBase64 });
|
||||||
|
|
||||||
|
// 2. Fetch profile (same session cookie — no second login)
|
||||||
|
if (onStatusChange) onStatusChange('Obteniendo perfil...');
|
||||||
|
const profile = await this.fetchProfileWithSession(sessionCookie);
|
||||||
|
|
||||||
|
if (onStatusChange) onStatusChange('¡Listo!');
|
||||||
|
return { credentialImage: imgBase64, profile };
|
||||||
|
}
|
||||||
|
|
||||||
async getUserProfile(dni: string, pass: string): Promise<{name: string, photo: string | null}> {
|
async getUserProfile(dni: string, pass: string): Promise<{name: string, photo: string | null}> {
|
||||||
try {
|
try {
|
||||||
await this.loginMoodle(dni, pass);
|
const sessionCookie = await this.loginMoodle(dni, pass);
|
||||||
|
return this.fetchProfileWithSession(sessionCookie);
|
||||||
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) {
|
} catch (e) {
|
||||||
console.error('Error fetching user profile:', e);
|
console.error('Error fetching user profile:', e);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loginMoodle(dni: string, pass: string): Promise<void> {
|
/**
|
||||||
// 1. Initial GET to fetch the login page and token
|
* Fetches the Moodle user profile (name + photo) using an already-authenticated session cookie.
|
||||||
const initialGet: HttpResponse = await CapacitorHttp.get({
|
* Does NOT perform a new login — use this after loginMoodle() to avoid a double-login.
|
||||||
url: this.LOGIN_URL,
|
*/
|
||||||
|
private async fetchProfileWithSession(sessionCookie: string): Promise<{name: string, photo: string | null}> {
|
||||||
|
const profileRes: HttpResponse = await CapacitorHttp.get({
|
||||||
|
url: 'https://campus.udelaciudad.edu.ar/user/profile.php',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
...(sessionCookie ? { 'Cookie': sessionCookie } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
const doc = parser.parseFromString(initialGet.data, 'text/html');
|
const doc = parser.parseFromString(profileRes.data, 'text/html');
|
||||||
const tokenInput = doc.querySelector('input[name="logintoken"]') as HTMLInputElement;
|
|
||||||
|
|
||||||
if (!tokenInput || !tokenInput.value) {
|
const nameTag = doc.querySelector('.card-profile h3');
|
||||||
|
const userName = nameTag ? nameTag.textContent?.trim() || '' : '';
|
||||||
|
|
||||||
|
const imgTag = doc.querySelector('.userpicture img') as HTMLImageElement;
|
||||||
|
let userPhotoBase64: string | null = null;
|
||||||
|
|
||||||
|
if (imgTag && imgTag.src) {
|
||||||
|
try {
|
||||||
|
const photoRes: HttpResponse = await CapacitorHttp.get({
|
||||||
|
url: imgTag.src,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
headers: {
|
||||||
|
...(sessionCookie ? { 'Cookie': sessionCookie } : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
userPhotoBase64 = `data:image/jpeg;base64,${photoRes.data}`;
|
||||||
|
} catch (imgError) {
|
||||||
|
console.error('Error downloading profile photo:', imgError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: userName, photo: userPhotoBase64 };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs the full Moodle login flow:
|
||||||
|
* 1. GET login page -> extract logintoken + session cookie
|
||||||
|
* 2. POST credentials (forwarding session cookie) -> receive authenticated session cookie
|
||||||
|
*
|
||||||
|
* Returns the authenticated session cookie string to be used in subsequent requests.
|
||||||
|
*
|
||||||
|
* IMPORTANT: CapacitorHttp in Capacitor 8+ does NOT maintain a shared cookie jar between
|
||||||
|
* requests. The logintoken is bound to the pre-login session cookie returned by the GET.
|
||||||
|
* Without forwarding that cookie in the POST, Moodle rejects the token as invalid.
|
||||||
|
* After a successful login, the POST response returns a new (authenticated) session cookie
|
||||||
|
* that must be forwarded to all subsequent authenticated requests.
|
||||||
|
*/
|
||||||
|
private async loginMoodle(dni: string, pass: string): Promise<string> {
|
||||||
|
// 1. GET login page — get the logintoken and the pre-session cookie
|
||||||
|
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',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language': 'es-AR,es;q=0.9'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug: log what CapacitorHttp actually received
|
||||||
|
console.log('[CredencialService] GET status:', initialGet.status);
|
||||||
|
console.log('[CredencialService] GET headers:', JSON.stringify(initialGet.headers));
|
||||||
|
const dataType = typeof initialGet.data;
|
||||||
|
const dataPreview = typeof initialGet.data === 'string'
|
||||||
|
? initialGet.data.substring(0, 300)
|
||||||
|
: `[non-string: ${dataType}]`;
|
||||||
|
console.log('[CredencialService] GET data preview:', dataPreview);
|
||||||
|
|
||||||
|
// Extract the pre-session cookie from the GET response headers
|
||||||
|
const preSessionCookie = this.extractCookies(initialGet.headers);
|
||||||
|
console.log('[CredencialService] Pre-session cookie:', preSessionCookie);
|
||||||
|
|
||||||
|
// Extract logintoken using REGEX — more robust than DOMParser on Android WebView,
|
||||||
|
// which can silently fail when CapacitorHttp returns HTML with encoding quirks.
|
||||||
|
let loginToken: string | null = null;
|
||||||
|
|
||||||
|
// Try multiple regex patterns to handle attribute order variations
|
||||||
|
const regexPatterns = [
|
||||||
|
/name="logintoken"\s+value="([^"]+)"/,
|
||||||
|
/value="([^"]+)"\s+name="logintoken"/,
|
||||||
|
/name='logintoken'\s+value='([^']+)'/,
|
||||||
|
/value='([^']+)'\s+name='logintoken'/,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (typeof initialGet.data === 'string') {
|
||||||
|
for (const pattern of regexPatterns) {
|
||||||
|
const m = initialGet.data.match(pattern);
|
||||||
|
if (m && m[1]) {
|
||||||
|
loginToken = m[1];
|
||||||
|
console.log('[CredencialService] Token found via regex:', loginToken);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: DOMParser
|
||||||
|
if (!loginToken) {
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(initialGet.data, 'text/html');
|
||||||
|
const tokenInput = doc.querySelector('input[name="logintoken"]') as HTMLInputElement;
|
||||||
|
loginToken = tokenInput?.value || null;
|
||||||
|
console.log('[CredencialService] Token via DOMParser:', loginToken);
|
||||||
|
} catch (parseErr) {
|
||||||
|
console.error('[CredencialService] DOMParser error:', parseErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('[CredencialService] GET data is not a string! Type:', dataType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loginToken) {
|
||||||
throw new Error('No se pudo obtener el token de seguridad del campus.');
|
throw new Error('No se pudo obtener el token de seguridad del campus.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginToken = tokenInput.value;
|
|
||||||
|
|
||||||
// 2. POST to login
|
|
||||||
|
// 2. POST login — forward the pre-session cookie so Moodle can validate the token
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('username', dni);
|
params.append('username', dni);
|
||||||
params.append('password', pass);
|
params.append('password', pass);
|
||||||
params.append('logintoken', loginToken);
|
params.append('logintoken', loginToken);
|
||||||
|
|
||||||
|
const postHeaders: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
};
|
||||||
|
if (preSessionCookie) {
|
||||||
|
postHeaders['Cookie'] = preSessionCookie;
|
||||||
|
}
|
||||||
|
|
||||||
const loginPost: HttpResponse = await CapacitorHttp.post({
|
const loginPost: HttpResponse = await CapacitorHttp.post({
|
||||||
url: this.LOGIN_URL,
|
url: this.LOGIN_URL,
|
||||||
data: params.toString(),
|
data: params.toString(),
|
||||||
headers: {
|
headers: postHeaders
|
||||||
'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
|
// Verify login success
|
||||||
if (loginPost.data && (loginPost.data.includes('loginerrormessage') || loginPost.data.includes('Inicie sesión en su cuenta'))) {
|
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/')) {
|
||||||
if (!loginPost.data.includes('user/profile.php') && !loginPost.data.includes('my/')) {
|
throw new Error('DNI o Clave incorrectos en el Campus.');
|
||||||
throw new Error('DNI o Clave incorrectos en el Campus.');
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return the authenticated session cookie from the POST response for use in further requests
|
||||||
|
const authenticatedCookie = this.extractCookies(loginPost.headers);
|
||||||
|
return authenticatedCookie || preSessionCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts cookies from response headers as a single "name=value; name2=value2" string.
|
||||||
|
* Handles both lowercase and uppercase "set-cookie" header names.
|
||||||
|
*/
|
||||||
|
private extractCookies(headers: Record<string, string> | undefined): string {
|
||||||
|
if (!headers) return '';
|
||||||
|
const rawSetCookie: string = headers['set-cookie'] || headers['Set-Cookie'] || '';
|
||||||
|
if (!rawSetCookie) return '';
|
||||||
|
// Multiple cookies may be joined by comma (not within a value), or in a single string
|
||||||
|
return rawSetCookie
|
||||||
|
.split(/,(?=[^ ])/) // split cookies separated by ", " (RFC 6265)
|
||||||
|
.map((c: string) => c.trim().split(';')[0]) // take only name=value part
|
||||||
|
.filter((c: string) => c.includes('='))
|
||||||
|
.join('; ');
|
||||||
}
|
}
|
||||||
|
|
||||||
private base64ToUint8Array(base64: string): Uint8Array {
|
private base64ToUint8Array(base64: string): Uint8Array {
|
||||||
@@ -171,22 +308,17 @@ export class CredencialService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async renderPdfToBase64(pdfData: Uint8Array): Promise<string> {
|
private async renderPdfToBase64(pdfData: Uint8Array): Promise<string> {
|
||||||
// Configure pdf.js to use the standard worker from local assets.
|
|
||||||
// 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;
|
const workerUrl = new URL('assets/pdf.worker.min.mjs', window.location.origin).href;
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl;
|
pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||||
|
|
||||||
const loadingTask = pdfjsLib.getDocument({ data: pdfData });
|
const loadingTask = pdfjsLib.getDocument({ data: pdfData });
|
||||||
const pdf = await loadingTask.promise;
|
const pdf = await loadingTask.promise;
|
||||||
|
|
||||||
// Get the first (and only) page
|
|
||||||
const page = await pdf.getPage(1);
|
const page = await pdf.getPage(1);
|
||||||
|
|
||||||
// Render at 2x scale for better resolution (similar to fitz.Matrix(2,2))
|
|
||||||
const scale = 2.0;
|
const scale = 2.0;
|
||||||
const viewport = page.getViewport({ scale });
|
const viewport = page.getViewport({ scale });
|
||||||
|
|
||||||
// Create a canvas to render the PDF
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
@@ -194,7 +326,6 @@ export class CredencialService {
|
|||||||
|
|
||||||
if (!context) throw new Error('Could not create Canvas context');
|
if (!context) throw new Error('Could not create Canvas context');
|
||||||
|
|
||||||
// Render PDF page into canvas context
|
|
||||||
const renderContext = {
|
const renderContext = {
|
||||||
canvasContext: context,
|
canvasContext: context,
|
||||||
viewport: viewport,
|
viewport: viewport,
|
||||||
@@ -203,11 +334,6 @@ export class CredencialService {
|
|||||||
|
|
||||||
await page.render(renderContext).promise;
|
await page.render(renderContext).promise;
|
||||||
|
|
||||||
// Convert canvas to base64 image (PNG)
|
|
||||||
// format "data:image/png;base64,..."
|
|
||||||
// We return just the base64 part to match python script, or the full data URI.
|
|
||||||
// The python script returned the raw base64, but the HTML added 'data:image/png...'.
|
|
||||||
// Let's return the naked base64 to be consistent.
|
|
||||||
const dataUri = canvas.toDataURL('image/png');
|
const dataUri = canvas.toDataURL('image/png');
|
||||||
const base64 = dataUri.split(',')[1];
|
const base64 = dataUri.split(',')[1];
|
||||||
|
|
||||||
|
|||||||
+16
@@ -1,3 +1,19 @@
|
|||||||
|
// Polyfill for Uint8Array.prototype.toHex — required by pdfjs-dist v5+.
|
||||||
|
// This method (part of TC39 proposal) is only natively available in Chrome 140+
|
||||||
|
// and is not yet supported by Android WebViews used in Capacitor apps.
|
||||||
|
if (!('toHex' in Uint8Array.prototype)) {
|
||||||
|
Object.defineProperty(Uint8Array.prototype, 'toHex', {
|
||||||
|
value(this: Uint8Array): string {
|
||||||
|
return Array.from(this)
|
||||||
|
.map(byte => byte.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
enumerable: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import sys
|
||||||
|
|
||||||
|
url = 'https://campus.udelaciudad.edu.ar/login/index.php'
|
||||||
|
session = requests.Session()
|
||||||
|
response = session.get(url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.text, 'html.parser')
|
||||||
|
token_input = soup.find('input', {'name': 'logintoken'})
|
||||||
|
if not token_input:
|
||||||
|
print("logintoken not found.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
logintoken = token_input.get('value')
|
||||||
|
print(f"Token: {logintoken}")
|
||||||
|
|
||||||
|
# POST login
|
||||||
|
login_data = {
|
||||||
|
'username': '34247025',
|
||||||
|
'password': 'SOY*Poderoso22',
|
||||||
|
'logintoken': logintoken
|
||||||
|
}
|
||||||
|
|
||||||
|
post_response = session.post(url, data=login_data, headers={
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"Login POST status: {post_response.status_code}")
|
||||||
|
if 'loginerrormessage' in post_response.text:
|
||||||
|
print("Login error message found!")
|
||||||
|
else:
|
||||||
|
print("No login errormessage found. Maybe success?")
|
||||||
|
|
||||||
|
# Try downloading the PDF
|
||||||
|
pdf_url = 'https://campus.udelaciudad.edu.ar/mod/customcert/view.php?id=209134&downloadown=1'
|
||||||
|
pdf_response = session.get(pdf_url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||||
|
print(f"PDF GET status: {pdf_response.status_code}")
|
||||||
|
print(f"Content-Type: {pdf_response.headers.get('Content-Type')}")
|
||||||
|
if 'application/pdf' in pdf_response.headers.get('Content-Type', ''):
|
||||||
|
print("Successfully downloaded PDF!")
|
||||||
|
else:
|
||||||
|
print("Did not download PDF. HTML start:")
|
||||||
|
print(pdf_response.text[:500])
|
||||||
Reference in New Issue
Block a user