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"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 14
|
||||
versionName "1.2.1"
|
||||
versionCode 15
|
||||
versionName "1.2.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// 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",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"author": "Ionic Framework",
|
||||
"homepage": "https://ionicframework.com/",
|
||||
"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-toolbar color="secondary">
|
||||
<div class="header-content">
|
||||
<img src="assets/logo_unicaba_mini.png" class="header-logo" alt="UNICABA Logo">
|
||||
<ion-title class="ion-text-center">
|
||||
UNICABA
|
||||
</ion-title>
|
||||
<div class="header-container-flex">
|
||||
<div class="header-logo-section" (click)="openInfo()">
|
||||
<img src="assets/logo_unicaba_mini.png" class="header-logo" alt="UNICABA Logo">
|
||||
</div>
|
||||
|
||||
<ion-buttons slot="end">
|
||||
<ion-button *ngIf="viewMode === 'favoritos'" (click)="importarRespaldoManual()" title="Importar Respaldo">
|
||||
<ion-icon slot="icon-only" name="cloud-download-outline"></ion-icon>
|
||||
<ion-button (click)="openCredencial()" title="Mi Credencial QR">
|
||||
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button (click)="goToPerfil()" title="Mi Perfil">
|
||||
<ion-icon slot="icon-only" name="person-circle-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button (click)="isInfoModalOpen = true">
|
||||
<ion-icon slot="icon-only" name="information-circle-outline"></ion-icon>
|
||||
<div class="user-avatar-small" *ngIf="authState?.userPhoto; else defaultIcon">
|
||||
<img [src]="authState?.userPhoto" />
|
||||
</div>
|
||||
<ng-template #defaultIcon>
|
||||
<ion-icon slot="icon-only" name="person-circle-outline"></ion-icon>
|
||||
</ng-template>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</div>
|
||||
@@ -37,69 +39,6 @@
|
||||
</ion-toolbar>
|
||||
</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-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
|
||||
<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;">
|
||||
<span class="selector-title" style="margin: 0;">Plan de Estudios / Recorrido</span>
|
||||
<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>
|
||||
<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-content {
|
||||
.header-container-flex {
|
||||
display: flex;
|
||||
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 {
|
||||
height: 36px;
|
||||
width: auto;
|
||||
background-color: white;
|
||||
height: 38px;
|
||||
background: white;
|
||||
padding: 4px;
|
||||
border-radius: 5px;
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
z-index: 10;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.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 { Subscription } from 'rxjs';
|
||||
|
||||
import { DataService, ClaseHorario, EventoAcademico } from '../services/data';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
@@ -13,8 +14,9 @@ import { LocalStorageService } from '../services/local-storage.service';
|
||||
import { EventosService } from '../services/eventos.service';
|
||||
import { EventoModalComponent } from '../components/evento-modal/evento-modal.component';
|
||||
import { AgendaModalComponent } from '../components/agenda-modal/agenda-modal.component';
|
||||
import { InfoModalComponent } from '../components/info-modal/info-modal.component';
|
||||
import { SiuService } from '../services/siu-guarani.service';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { AuthService, AuthState } from '../services/auth.service';
|
||||
|
||||
|
||||
export interface ProximaClaseInfo {
|
||||
@@ -44,6 +46,9 @@ export class HomePage implements OnInit {
|
||||
errorMsg: string = '';
|
||||
isInfoModalOpen: boolean = false;
|
||||
|
||||
authState: AuthState | null = null;
|
||||
private authSub: Subscription | null = null;
|
||||
|
||||
proximaClase: ProximaClaseInfo | null = null;
|
||||
private countdownTimer: any = null;
|
||||
|
||||
@@ -95,6 +100,10 @@ export class HomePage implements OnInit {
|
||||
|
||||
|
||||
async ngOnInit() {
|
||||
this.authSub = this.authService.authState$.subscribe(state => {
|
||||
this.authState = state;
|
||||
});
|
||||
|
||||
this.route.queryParams.subscribe(params => {
|
||||
this.highlightedCodigo = params['highlight'] || null;
|
||||
if (this.highlightedCodigo) {
|
||||
@@ -118,10 +127,8 @@ export class HomePage implements OnInit {
|
||||
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();
|
||||
}
|
||||
@@ -173,8 +180,6 @@ export class HomePage implements OnInit {
|
||||
if (favPref.value && JSON.parse(favPref.value).length > 0) {
|
||||
this.favoritos = JSON.parse(favPref.value);
|
||||
this.viewMode = 'favoritos';
|
||||
} else {
|
||||
await this.promptForRestore();
|
||||
}
|
||||
this.eventos = await this.eventosService.loadEventos();
|
||||
}
|
||||
@@ -191,6 +196,13 @@ export class HomePage implements OnInit {
|
||||
|
||||
// 3. Fetch from Server
|
||||
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) {
|
||||
@@ -245,46 +257,6 @@ export class HomePage implements OnInit {
|
||||
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() {
|
||||
let baseList = this.horarios;
|
||||
@@ -581,6 +553,20 @@ export class HomePage implements OnInit {
|
||||
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) ---
|
||||
|
||||
@@ -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() {
|
||||
this.navCtrl.navigateForward('/perfil');
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
<ion-back-button defaultHref="/home"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<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-header>
|
||||
|
||||
@@ -34,7 +39,8 @@
|
||||
</ion-card-header>
|
||||
|
||||
<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-item>
|
||||
<ion-icon name="person-outline" slot="start" color="medium"></ion-icon>
|
||||
@@ -42,7 +48,8 @@
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<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-icon [name]="showSiuPass ? 'eye-off-outline' : 'eye-outline'"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -83,7 +90,8 @@
|
||||
</ion-card-header>
|
||||
|
||||
<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-item>
|
||||
<ion-icon name="person-outline" slot="start" color="medium"></ion-icon>
|
||||
@@ -91,7 +99,8 @@
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<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-icon [name]="showMoodlePass ? 'eye-off-outline' : 'eye-outline'"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -119,9 +128,50 @@
|
||||
</ion-card-content>
|
||||
</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">
|
||||
<ion-icon name="shield-checkmark" 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>
|
||||
<ion-icon name="lock-closed" color="medium"></ion-icon>
|
||||
<p>Tus credenciales y datos se almacenan de forma segura y local en el almacenamiento persistente de tu teléfono.
|
||||
</p>
|
||||
</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 {
|
||||
color: var(--ion-color-danger);
|
||||
font-size: 0.85rem;
|
||||
@@ -180,15 +245,13 @@
|
||||
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;
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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 { SiuService } from '../services/siu-guarani.service';
|
||||
import { CredencialService } from '../services/credencial.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';
|
||||
|
||||
@Component({
|
||||
@@ -32,9 +37,12 @@ export class PerfilPage implements OnInit, OnDestroy {
|
||||
private siuService: SiuService,
|
||||
private credencialService: CredencialService,
|
||||
private localStorageService: LocalStorageService,
|
||||
private eventosService: EventosService,
|
||||
private toastCtrl: ToastController,
|
||||
private loadingCtrl: LoadingController,
|
||||
private alertCtrl: AlertController,
|
||||
private actionSheetCtrl: ActionSheetController,
|
||||
private modalCtrl: ModalController,
|
||||
private navCtrl: NavController
|
||||
) { }
|
||||
|
||||
@@ -115,16 +123,19 @@ export class PerfilPage implements OnInit, OnDestroy {
|
||||
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);
|
||||
// setupMoodleAccount hace UNA sola autenticación y luego descarga la credencial
|
||||
// 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
|
||||
);
|
||||
|
||||
// 3. Guardar credenciales de acceso
|
||||
await this.authService.saveUserProfile(result.profile.name, result.profile.photo);
|
||||
await this.authService.saveMoodleCredentials(this.moodleDni, this.moodlePass);
|
||||
|
||||
|
||||
this.showToast('¡Perfil sincronizado y Campus conectado!', 'success');
|
||||
this.moodlePass = '';
|
||||
} catch (e: any) {
|
||||
@@ -136,6 +147,7 @@ export class PerfilPage implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async logoutMoodle() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Cerrar Sesión en Campus',
|
||||
@@ -156,6 +168,121 @@ export class PerfilPage implements OnInit, OnDestroy {
|
||||
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() {
|
||||
this.navCtrl.back();
|
||||
}
|
||||
|
||||
@@ -4,10 +4,6 @@ import { AuthService } from './auth.service';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
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({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -15,7 +11,7 @@ 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_IMAGE = 'credencial_base64';
|
||||
|
||||
constructor(private authService: AuthService) {}
|
||||
@@ -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 -> Scrape Token -> Submit Login -> Download PDF -> Render Base64
|
||||
* Executes the full flow: Login -> Download PDF -> Render Base64
|
||||
*/
|
||||
async generateCredential(dni: string, pass: string, onStatusChange?: (status: string) => void): Promise<string> {
|
||||
try {
|
||||
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...');
|
||||
|
||||
// 3. Download the PDF
|
||||
// Download the PDF, forwarding the authenticated session cookie
|
||||
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'
|
||||
'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)...');
|
||||
|
||||
// 4. Render PDF to Base64 Image using pdf.js
|
||||
// Render PDF to Base64 Image using pdf.js
|
||||
const imgBase64 = await this.renderPdfToBase64(pdfData);
|
||||
|
||||
// Save to cache securely
|
||||
// Save to cache
|
||||
await Preferences.set({ key: this.PREF_IMAGE, value: imgBase64 });
|
||||
|
||||
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}> {
|
||||
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 };
|
||||
const sessionCookie = await this.loginMoodle(dni, pass);
|
||||
return this.fetchProfileWithSession(sessionCookie);
|
||||
} catch (e) {
|
||||
console.error('Error fetching user profile:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async loginMoodle(dni: string, pass: string): Promise<void> {
|
||||
// 1. Initial GET to fetch the login page and token
|
||||
const initialGet: HttpResponse = await CapacitorHttp.get({
|
||||
url: this.LOGIN_URL,
|
||||
/**
|
||||
* Fetches the Moodle user profile (name + photo) using an already-authenticated session cookie.
|
||||
* Does NOT perform a new login — use this after loginMoodle() to avoid a double-login.
|
||||
*/
|
||||
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: {
|
||||
'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 doc = parser.parseFromString(initialGet.data, 'text/html');
|
||||
const tokenInput = doc.querySelector('input[name="logintoken"]') as HTMLInputElement;
|
||||
|
||||
if (!tokenInput || !tokenInput.value) {
|
||||
const doc = parser.parseFromString(profileRes.data, 'text/html');
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
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();
|
||||
params.append('username', dni);
|
||||
params.append('password', pass);
|
||||
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({
|
||||
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'
|
||||
}
|
||||
headers: postHeaders
|
||||
});
|
||||
|
||||
// Verify if login was successful
|
||||
// Verify login success
|
||||
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.');
|
||||
}
|
||||
if (!loginPost.data.includes('user/profile.php') && !loginPost.data.includes('my/')) {
|
||||
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 {
|
||||
@@ -171,22 +308,17 @@ export class CredencialService {
|
||||
}
|
||||
|
||||
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;
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
|
||||
const loadingTask = pdfjsLib.getDocument({ data: pdfData });
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
// Get the first (and only) page
|
||||
|
||||
const page = await pdf.getPage(1);
|
||||
|
||||
// Render at 2x scale for better resolution (similar to fitz.Matrix(2,2))
|
||||
|
||||
const scale = 2.0;
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
// Create a canvas to render the PDF
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
canvas.height = viewport.height;
|
||||
@@ -194,23 +326,17 @@ export class CredencialService {
|
||||
|
||||
if (!context) throw new Error('Could not create Canvas context');
|
||||
|
||||
// Render PDF page into canvas context
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport: viewport,
|
||||
canvas: canvas
|
||||
};
|
||||
|
||||
|
||||
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 base64 = dataUri.split(',')[1];
|
||||
|
||||
|
||||
return base64;
|
||||
}
|
||||
}
|
||||
|
||||
+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 { 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