feat: gestion RBAC granular, importacion sheets, filtrado de aulas virtuales y pisos, capacidad infinita y traduccion
@@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () => import('./home/home.module').then( m => m.HomePageModule)
|
||||
},
|
||||
{
|
||||
path: 'perfil',
|
||||
loadChildren: () => import('./perfil/perfil.module').then( m => m.PerfilPageModule)
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'home',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
|
||||
],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
@@ -0,0 +1,3 @@
|
||||
<ion-app>
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
</ion-app>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [AppComponent],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Component, OnInit, NgZone } from '@angular/core';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { BackgroundFetch } from '@transistorsoft/capacitor-background-fetch';
|
||||
import { Network } from '@capacitor/network';
|
||||
import { DataService } from './services/data';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
constructor(
|
||||
private dataService: DataService,
|
||||
private router: Router,
|
||||
private zone: NgZone
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.setupBackgroundFetch();
|
||||
this.setupNotificationAction();
|
||||
}
|
||||
|
||||
private async setupBackgroundFetch() {
|
||||
const status = await Network.getStatus();
|
||||
// WiFi: 15 min, Mobile: 60 min
|
||||
const interval = status.connectionType === 'wifi' ? 15 : 60;
|
||||
|
||||
BackgroundFetch.configure({
|
||||
minimumFetchInterval: interval,
|
||||
stopOnTerminate: false,
|
||||
startOnBoot: true,
|
||||
enableHeadless: true
|
||||
}, async (taskId: string) => {
|
||||
console.log('[BackgroundFetch] Event received: ', taskId);
|
||||
await this.performSync();
|
||||
BackgroundFetch.finish(taskId);
|
||||
}, (error: string) => {
|
||||
console.error('[BackgroundFetch] FAILED: ', error);
|
||||
});
|
||||
}
|
||||
|
||||
private async performSync() {
|
||||
try {
|
||||
// 1. Obtener favoritos
|
||||
const favs = await this.dataService.getFavorites();
|
||||
// 2. Fetch y Sync (esto ya dispara notificaciones si hay cambios)
|
||||
this.dataService.getHorarios().subscribe(async (newData) => {
|
||||
await this.dataService.syncWithCache(newData, favs || []);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[BackgroundSync] Error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private setupNotificationAction() {
|
||||
LocalNotifications.addListener('localNotificationActionPerformed', (notification) => {
|
||||
const codigo = notification.notification.extra?.codigo;
|
||||
if (codigo) {
|
||||
this.zone.run(() => {
|
||||
// Navegar a home y pasar el código para resaltar/scroll
|
||||
this.router.navigate(['/home'], { queryParams: { highlight: codigo } });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { RouteReuseStrategy } from '@angular/router';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
|
||||
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
|
||||
|
||||
import { AppComponent } from './app.component';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule, HttpClientModule],
|
||||
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule { }
|
||||
@@ -0,0 +1,48 @@
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary">
|
||||
<ion-title>Agenda Académica</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">Cerrar</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding" color="light">
|
||||
<div class="materia-info">
|
||||
<h2>{{ materia.asignatura }}</h2>
|
||||
<p>Código: {{ materia.codigo }}</p>
|
||||
</div>
|
||||
|
||||
<ion-button expand="block" (click)="abrirEditor()" class="add-btn">
|
||||
<ion-icon name="add-circle-outline" slot="start"></ion-icon>
|
||||
Agendar Nuevo Evento
|
||||
</ion-button>
|
||||
|
||||
<div class="empty-state" *ngIf="eventos.length === 0">
|
||||
<ion-icon name="calendar-clear-outline"></ion-icon>
|
||||
<p>No hay eventos programados.</p>
|
||||
</div>
|
||||
|
||||
<ion-list *ngIf="eventos.length > 0" class="agenda-list">
|
||||
<ion-item *ngFor="let ev of eventos" button (click)="abrirEditor(ev)" lines="full" class="agenda-item">
|
||||
<div class="item-content">
|
||||
<div class="item-header">
|
||||
<ion-badge color="secondary">{{ ev.tipo }}</ion-badge>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="fecha-hora">
|
||||
<ion-icon name="calendar-outline"></ion-icon> {{ formatFecha(ev.fecha) }}
|
||||
<ion-icon name="time-outline" class="icon-space"></ion-icon> {{ formatHora(ev.fecha) }} hs
|
||||
</p>
|
||||
<p class="aula" *ngIf="ev.aula">
|
||||
<ion-icon name="location-outline"></ion-icon> Aula: {{ ev.aula }}
|
||||
</p>
|
||||
<p class="notas" *ngIf="ev.notas">
|
||||
{{ ev.notas }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ion-icon name="chevron-forward-outline" slot="end" color="medium"></ion-icon>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,77 @@
|
||||
.materia-info {
|
||||
margin-bottom: 20px;
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ion-color-medium);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
margin-bottom: 20px;
|
||||
--border-radius: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
color: var(--ion-color-medium);
|
||||
|
||||
ion-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.agenda-list {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agenda-item {
|
||||
--background: var(--ion-card-background, #ffffff);
|
||||
--border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
|
||||
.item-content {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
p {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ion-text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
ion-icon {
|
||||
margin-right: 6px;
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
|
||||
&.notas {
|
||||
color: var(--ion-color-medium);
|
||||
font-style: italic;
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-space {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { AgendaModalComponent } from './agenda-modal.component';
|
||||
|
||||
describe('AgendaModalComponent', () => {
|
||||
let component: AgendaModalComponent;
|
||||
let fixture: ComponentFixture<AgendaModalComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AgendaModalComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AgendaModalComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { IonicModule, ModalController } from '@ionic/angular';
|
||||
import { EventoAcademico, ClaseHorario } from '../../services/data';
|
||||
import { EventosService } from '../../services/eventos.service';
|
||||
import { EventoModalComponent } from '../evento-modal/evento-modal.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-agenda-modal',
|
||||
templateUrl: './agenda-modal.component.html',
|
||||
styleUrls: ['./agenda-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, IonicModule]
|
||||
})
|
||||
export class AgendaModalComponent implements OnInit {
|
||||
@Input() materia!: ClaseHorario;
|
||||
eventos: EventoAcademico[] = [];
|
||||
|
||||
constructor(
|
||||
private modalCtrl: ModalController,
|
||||
private eventosService: EventosService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.cargarEventos();
|
||||
}
|
||||
|
||||
ionViewWillEnter() {
|
||||
this.cargarEventos();
|
||||
}
|
||||
|
||||
cargarEventos() {
|
||||
this.eventos = this.eventosService.getEventosByMateria(this.materia.codigo)
|
||||
.sort((a, b) => new Date(a.fecha).getTime() - new Date(b.fecha).getTime());
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss();
|
||||
}
|
||||
|
||||
formatFecha(fechaIso: string): string {
|
||||
const d = new Date(fechaIso);
|
||||
return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
formatHora(fechaIso: string): string {
|
||||
const d = new Date(fechaIso);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
async abrirEditor(evento?: EventoAcademico) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: EventoModalComponent,
|
||||
componentProps: {
|
||||
materiaCodigo: this.materia.codigo,
|
||||
materiaNombre: this.materia.asignatura,
|
||||
eventoExistente: evento
|
||||
}
|
||||
});
|
||||
|
||||
await modal.present();
|
||||
|
||||
const { role } = await modal.onWillDismiss();
|
||||
if (role === 'saved' || role === 'deleted') {
|
||||
this.cargarEventos();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary">
|
||||
<ion-title>Credencial Digital</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="close()">Cerrar</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="credential-content">
|
||||
|
||||
<!-- Loading State -->
|
||||
<div class="state-container" *ngIf="state === 'loading'">
|
||||
<ion-spinner name="crescent" color="primary"></ion-spinner>
|
||||
<div class="loading-wrapper ion-text-center">
|
||||
<p class="loading-text">{{ loadingStatus }}</p>
|
||||
<p class="loading-subtext" style="font-size: 0.8rem; color: var(--ion-color-medium); margin-top: 5px;">
|
||||
Por favor no cierres la aplicación
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div class="state-container error-container" *ngIf="state === 'error'">
|
||||
<ion-icon name="alert-circle-outline" color="danger"></ion-icon>
|
||||
<p class="error-text">{{ errorMessage }}</p>
|
||||
<ion-button color="primary" fill="outline" (click)="resetState()">Intentar nuevamente</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- Login State -->
|
||||
<div class="state-container login-container" *ngIf="state === 'login'">
|
||||
<div class="login-card">
|
||||
<h2 class="ion-text-center">Mi Credencial</h2>
|
||||
<p class="ion-text-center text-muted">Ingresa con tus datos del campus virtual para generar tu credencial digital. Los datos se procesan de forma segura en tu dispositivo.</p>
|
||||
|
||||
<ion-item>
|
||||
<ion-label position="floating">DNI</ion-label>
|
||||
<ion-input type="number" [(ngModel)]="dni"></ion-input>
|
||||
</ion-item>
|
||||
|
||||
<ion-item>
|
||||
<ion-label position="floating">Contraseña</ion-label>
|
||||
<ion-input [type]="showPassword ? 'text' : 'password'" [(ngModel)]="password"></ion-input>
|
||||
<ion-button fill="clear" slot="end" class="ion-align-self-end" (click)="showPassword = !showPassword" style="margin-top: 15px;">
|
||||
<ion-icon slot="icon-only" [name]="showPassword ? 'eye-off-outline' : 'eye-outline'" color="medium"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
|
||||
<ion-button expand="block" class="ion-margin-top" (click)="generate()" [disabled]="!dni || !password">
|
||||
Generar / Ver
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Credential State -->
|
||||
<div class="state-container credential-container" *ngIf="state === 'success'">
|
||||
<div class="credential-wrapper">
|
||||
<img [src]="'data:image/png;base64,' + credentialImage" class="credential-img" alt="Credencial UNICABA" />
|
||||
</div>
|
||||
|
||||
<div class="credential-actions">
|
||||
<ion-button expand="block" color="primary" fill="outline" (click)="generate(true)">
|
||||
<ion-icon name="refresh-outline" slot="start"></ion-icon>
|
||||
Actualizar Credencial
|
||||
</ion-button>
|
||||
|
||||
<ion-button expand="block" color="danger" fill="clear" (click)="logout()">
|
||||
<ion-icon name="log-out-outline" slot="start"></ion-icon>
|
||||
Cerrar Sesión
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ion-content>
|
||||
@@ -0,0 +1,93 @@
|
||||
.state-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 80vh;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 20px;
|
||||
color: var(--ion-color-medium);
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
ion-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.error-text {
|
||||
color: var(--ion-color-danger);
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.login-container {
|
||||
.login-card {
|
||||
background: var(--ion-card-background, #ffffff);
|
||||
color: var(--ion-text-color, #000000);
|
||||
padding: 30px 20px;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
|
||||
h2 {
|
||||
color: var(--ion-color-primary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--ion-color-medium);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--background: transparent;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.credential-container {
|
||||
justify-content: flex-start;
|
||||
min-height: auto;
|
||||
padding: 8px 8px 24px 8px;
|
||||
width: 100%;
|
||||
|
||||
.credential-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.credential-img {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
|
||||
border: 1px solid #efefef;
|
||||
margin-bottom: 18px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.credential-actions {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
padding: 0 4px;
|
||||
|
||||
ion-button {
|
||||
margin-top: 6px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { IonicModule, ModalController, ToastController } from '@ionic/angular';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CredencialService } from '../../services/credencial.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { ScreenBrightness } from '@capacitor-community/screen-brightness';
|
||||
|
||||
type CredencialState = 'loading' | 'login' | 'success' | 'error';
|
||||
|
||||
@Component({
|
||||
selector: 'app-credencial-modal',
|
||||
templateUrl: './credencial-modal.component.html',
|
||||
styleUrls: ['./credencial-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, IonicModule, FormsModule]
|
||||
})
|
||||
export class CredencialModalComponent implements OnInit, OnDestroy {
|
||||
|
||||
state: CredencialState = 'loading';
|
||||
errorMessage: string = '';
|
||||
loadingStatus: string = 'Iniciando...';
|
||||
|
||||
dni: string = '';
|
||||
password: string = '';
|
||||
|
||||
credentialImage: string = '';
|
||||
showPassword: boolean = false;
|
||||
originalBrightness: number | null = null;
|
||||
|
||||
constructor(
|
||||
private modalCtrl: ModalController,
|
||||
private credencialService: CredencialService,
|
||||
private authService: AuthService,
|
||||
private toastCtrl: ToastController
|
||||
) { }
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const { brightness } = await ScreenBrightness.getBrightness();
|
||||
this.originalBrightness = brightness;
|
||||
await ScreenBrightness.setBrightness({ brightness: 1 });
|
||||
} catch (e) {
|
||||
console.warn('Brightness not supported on the web platform', e);
|
||||
}
|
||||
await this.checkExistingCredential();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.originalBrightness !== null) {
|
||||
ScreenBrightness.setBrightness({ brightness: this.originalBrightness }).catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
async checkExistingCredential() {
|
||||
this.state = 'loading';
|
||||
this.loadingStatus = 'Comprobando caché...';
|
||||
try {
|
||||
// 1. Try to load cached image first for instant load
|
||||
const cachedImg = await this.credencialService.getCachedImage();
|
||||
if (cachedImg) {
|
||||
this.credentialImage = cachedImg;
|
||||
this.state = 'success';
|
||||
|
||||
// Even if we show cache, load credentials to fields in background in case they want to update
|
||||
const stored = await this.authService.getMoodleCredentials();
|
||||
this.dni = stored.dni || '';
|
||||
this.password = stored.pass || '';
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If no cached image, check for credentials to auto-login
|
||||
const stored = await this.authService.getMoodleCredentials();
|
||||
if (stored.dni && stored.pass) {
|
||||
this.dni = stored.dni;
|
||||
this.password = stored.pass;
|
||||
await this.generate(false);
|
||||
} else {
|
||||
// 3. Need user to enter credentials
|
||||
this.state = 'login';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error on init:', error);
|
||||
this.state = 'login';
|
||||
}
|
||||
}
|
||||
|
||||
async generate(forceUpdate: boolean = false) {
|
||||
if (!this.dni || !this.password) return;
|
||||
|
||||
this.state = 'loading';
|
||||
try {
|
||||
this.credentialImage = await this.credencialService.generateCredential(
|
||||
this.dni,
|
||||
this.password,
|
||||
(status) => { this.loadingStatus = status; }
|
||||
);
|
||||
this.state = 'success';
|
||||
|
||||
if (forceUpdate) {
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Credencial actualizada correctamente',
|
||||
duration: 2000,
|
||||
color: 'success'
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error generating credential:', err);
|
||||
this.errorMessage = err.message || 'Error al conectar con el campus.';
|
||||
this.state = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.authService.logoutMoodle();
|
||||
this.dni = '';
|
||||
this.password = '';
|
||||
this.credentialImage = '';
|
||||
this.state = 'login';
|
||||
}
|
||||
|
||||
resetState() {
|
||||
// Determine where to back out: usually to login
|
||||
this.state = 'login';
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modalCtrl.dismiss();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary">
|
||||
<ion-title>{{ isEdit ? 'Editar' : 'Programar' }} Hito</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">Cerrar</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<div class="materia-info">
|
||||
<small>Materia:</small>
|
||||
<h3>{{ materiaNombre }}</h3>
|
||||
</div>
|
||||
|
||||
<ion-list lines="inset" class="compact-list">
|
||||
<ion-item class="input-item">
|
||||
<ion-label position="stacked">Tipo e Hito</ion-label>
|
||||
<ion-select [(ngModel)]="tipo" interface="popover">
|
||||
<ion-select-option value="Parcial 1">Parcial 1</ion-select-option>
|
||||
<ion-select-option value="Parcial 2">Parcial 2</ion-select-option>
|
||||
<ion-select-option value="TP 1">Trabajo Práctico 1</ion-select-option>
|
||||
<ion-select-option value="TP 2">Trabajo Práctico 2</ion-select-option>
|
||||
<ion-select-option value="TP 3">Trabajo Práctico 3</ion-select-option>
|
||||
<ion-select-option value="Recu. P1">Recuperatorio Parcial 1</ion-select-option>
|
||||
<ion-select-option value="Recu. P2">Recuperatorio Parcial 2</ion-select-option>
|
||||
<ion-select-option value="Recu. TP 1">Recuperatorio TP 1</ion-select-option>
|
||||
<ion-select-option value="Recu. TP 2">Recuperatorio TP 2</ion-select-option>
|
||||
<ion-select-option value="Recu. TP 3">Recuperatorio TP 3</ion-select-option>
|
||||
<ion-select-option value="Final">Final</ion-select-option>
|
||||
<ion-select-option value="Otro">Otro</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
|
||||
<ion-grid class="ion-no-padding">
|
||||
<ion-row>
|
||||
<ion-col size="6">
|
||||
<ion-item class="input-item side-item">
|
||||
<ion-label position="stacked">Día del Hito</ion-label>
|
||||
<ion-datetime-button datetime="date-input"></ion-datetime-button>
|
||||
<ion-popover [keepContentsMounted]="true">
|
||||
<ng-template>
|
||||
<ion-datetime id="date-input" [(ngModel)]="fecha" presentation="date" prefer-wheel="true" [showDefaultButtons]="true" doneText="OK" cancelText="Atrás"></ion-datetime>
|
||||
</ng-template>
|
||||
</ion-popover>
|
||||
</ion-item>
|
||||
</ion-col>
|
||||
<ion-col size="6">
|
||||
<ion-item class="input-item side-item">
|
||||
<ion-label position="stacked">Hora aprox.</ion-label>
|
||||
<ion-datetime-button datetime="time-input"></ion-datetime-button>
|
||||
<ion-popover [keepContentsMounted]="true">
|
||||
<ng-template>
|
||||
<ion-datetime id="time-input" [(ngModel)]="fecha" presentation="time" prefer-wheel="true" [showDefaultButtons]="true" doneText="OK" cancelText="Atrás"></ion-datetime>
|
||||
</ng-template>
|
||||
</ion-popover>
|
||||
</ion-item>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
|
||||
<ion-grid class="ion-no-padding">
|
||||
<ion-row>
|
||||
<ion-col size="6">
|
||||
<ion-item class="input-item side-item">
|
||||
<ion-label position="stacked">Aula / Zoom</ion-label>
|
||||
<ion-input type="text" [(ngModel)]="aula" placeholder="Ej: 102"></ion-input>
|
||||
</ion-item>
|
||||
</ion-col>
|
||||
<ion-col size="6">
|
||||
<ion-item class="input-item side-item">
|
||||
<ion-label position="stacked">Link</ion-label>
|
||||
<ion-input type="url" [(ngModel)]="link" placeholder="https://"></ion-input>
|
||||
</ion-item>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
|
||||
<ion-item class="input-item">
|
||||
<ion-label position="stacked">Notas</ion-label>
|
||||
<ion-textarea [(ngModel)]="notas" placeholder="Breve descripción..." rows="2"></ion-textarea>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
|
||||
<div class="actions-container">
|
||||
<ion-button expand="block" color="secondary" (click)="guardar()" class="main-btn">
|
||||
{{ isEdit ? 'Actualizar Agenda' : 'Agendar Hito' }}
|
||||
</ion-button>
|
||||
|
||||
<ion-button expand="block" fill="outline" color="dark" (click)="exportarCalendario()" class="export-btn" style="margin-top: 10px;">
|
||||
<ion-icon name="calendar-outline" slot="start"></ion-icon>
|
||||
Añadir a Google Calendar
|
||||
</ion-button>
|
||||
|
||||
<ion-button *ngIf="isEdit" expand="block" fill="clear" color="danger" (click)="eliminar()" class="delete-btn">
|
||||
<ion-icon name="trash-outline" slot="start"></ion-icon>
|
||||
Eliminar Evento
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,52 @@
|
||||
.materia-info {
|
||||
margin-top: 10px;
|
||||
h3 {
|
||||
margin: 5px 0 20px;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.input-item {
|
||||
--padding-start: 10px;
|
||||
--padding-end: 10px;
|
||||
--padding-top: 5px;
|
||||
--padding-bottom: 5px;
|
||||
--min-height: 45px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--ion-color-light);
|
||||
|
||||
ion-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px !important;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.side-item {
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
|
||||
ion-datetime-button {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.main-btn {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
--color: var(--ion-color-danger);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { IonicModule, ModalController, AlertController, ToastController } from '@ionic/angular';
|
||||
import { EventoAcademico } from '../../services/data';
|
||||
import { EventosService } from '../../services/eventos.service';
|
||||
import { CapacitorCalendar } from '@ebarooni/capacitor-calendar';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-evento-modal',
|
||||
templateUrl: './evento-modal.component.html',
|
||||
styleUrls: ['./evento-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, IonicModule, FormsModule]
|
||||
})
|
||||
export class EventoModalComponent implements OnInit {
|
||||
@Input() materiaCodigo!: string;
|
||||
@Input() materiaNombre!: string;
|
||||
@Input() eventoExistente?: EventoAcademico;
|
||||
|
||||
tipo: 'Parcial' | 'TP' | 'Final' | 'Otro' = 'Parcial';
|
||||
fecha: string = new Date().toISOString();
|
||||
aula: string = '';
|
||||
link: string = '';
|
||||
notas: string = '';
|
||||
|
||||
isEdit: boolean = false;
|
||||
|
||||
constructor(
|
||||
private modalCtrl: ModalController,
|
||||
private eventosService: EventosService,
|
||||
private alertCtrl: AlertController,
|
||||
private toastCtrl: ToastController
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
if (this.eventoExistente) {
|
||||
this.isEdit = true;
|
||||
this.tipo = this.eventoExistente.tipo;
|
||||
this.fecha = this.eventoExistente.fecha;
|
||||
this.aula = this.eventoExistente.aula || '';
|
||||
this.link = this.eventoExistente.link || '';
|
||||
this.notas = this.eventoExistente.notas || '';
|
||||
}
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss();
|
||||
}
|
||||
|
||||
async guardar() {
|
||||
const nuevoEvento: EventoAcademico = {
|
||||
id: this.isEdit ? this.eventoExistente!.id : Date.now().toString(),
|
||||
materiaCodigo: this.materiaCodigo,
|
||||
tipo: this.tipo,
|
||||
fecha: this.fecha,
|
||||
aula: this.aula,
|
||||
link: this.link,
|
||||
notas: this.notas
|
||||
};
|
||||
|
||||
await this.eventosService.saveEvento(nuevoEvento);
|
||||
this.modalCtrl.dismiss(nuevoEvento, 'saved');
|
||||
}
|
||||
|
||||
async eliminar() {
|
||||
if (this.eventoExistente) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Eliminar Evento',
|
||||
message: '¿Estás seguro que querés eliminar este hito de tu agenda?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancelar',
|
||||
role: 'cancel'
|
||||
},
|
||||
{
|
||||
text: 'Eliminar',
|
||||
role: 'destructive',
|
||||
handler: async () => {
|
||||
await this.eventosService.deleteEvento(this.eventoExistente!.id);
|
||||
this.modalCtrl.dismiss(null, 'deleted');
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
}
|
||||
|
||||
async exportarCalendario() {
|
||||
try {
|
||||
const startDate = new Date(this.fecha);
|
||||
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // +2 horas por defecto
|
||||
|
||||
let descString = this.notas || '';
|
||||
if (this.link) descString += `\n\nLink de cursada: ${this.link}`;
|
||||
|
||||
await CapacitorCalendar.createEventWithPrompt({
|
||||
title: `${this.tipo} de ${this.materiaNombre}`,
|
||||
description: descString,
|
||||
location: this.aula ? `Aula ${this.aula}, Universidad de la Ciudad` : 'Universidad de la Ciudad',
|
||||
startDate: startDate.getTime(),
|
||||
endDate: endDate.getTime(),
|
||||
isAllDay: false
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error abriendo enlace de Calendario:', e);
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'No pudimos abrir tu Calendario. Verificá si tenés una app instalada.',
|
||||
duration: 2500, color: 'warning'
|
||||
});
|
||||
toast.present();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
<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="benefits">
|
||||
<ion-label>Beneficios</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.3 • Independiente y Offline-First</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VISTA: BENEFICIOS -->
|
||||
<div *ngIf="selectedSegment === 'benefits'" class="fade-in">
|
||||
<div class="logo-container ion-text-center">
|
||||
<ion-icon name="gift-outline" size="large" color="tertiary"></ion-icon>
|
||||
<h2>Comunidad de Beneficios</h2>
|
||||
<p class="subtitle">Descuentos exclusivos para la Comunidad UdelaCiudad</p>
|
||||
</div>
|
||||
|
||||
<div class="benefits-container">
|
||||
|
||||
<!-- ON FIT (DESTACADO) -->
|
||||
<div class="benefit-card featured">
|
||||
<div class="card-top">
|
||||
<div class="brand-info">
|
||||
<div class="brand-icon gym">
|
||||
<ion-icon name="barbell-outline"></ion-icon>
|
||||
</div>
|
||||
<div class="title-wrap">
|
||||
<h3>ON FIT</h3>
|
||||
<p class="category">Gimnasio & Bienestar</p>
|
||||
</div>
|
||||
</div>
|
||||
<ion-badge color="success" class="discount-badge">25% OFF</ion-badge>
|
||||
</div>
|
||||
<p class="benefit-desc">
|
||||
25% de descuento sobre el valor de la membresía mensual con acceso libre a sus 20 sedes en todo el AMBA.
|
||||
</p>
|
||||
<div class="inclusion-box">
|
||||
<h4>¿Qué incluye la membresía?</h4>
|
||||
<ul>
|
||||
<li>Instalaciones con máquinas Technogym de última generación.</li>
|
||||
<li>Plan de entrenamiento personalizado, actualizado mensualmente.</li>
|
||||
<li>Clases grupales: funcional, zumba, stretching y más.</li>
|
||||
<li>Lockers gratuitos en todas las sedes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-row">
|
||||
<ion-icon name="information-circle-outline"></ion-icon>
|
||||
<span>Presentá tu Credencial Digital de la app para acreditar pertenencia a la Universidad.</span>
|
||||
</div>
|
||||
</div>
|
||||
<ion-button expand="block" color="success" class="action-btn"
|
||||
href="https://wa.me/5491158500830?text=Hola!%20Soy%20de%20la%20Universidad%20de%20la%20Ciudad%20y%20deseo%20acceder%20al%20beneficio%20ON%20FIT"
|
||||
target="_blank">
|
||||
<ion-icon name="logo-whatsapp" slot="start"></ion-icon>
|
||||
Consultar por WhatsApp (+54 9 11 5850-0830)
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- BERESHIT -->
|
||||
<div class="benefit-card">
|
||||
<div class="card-top">
|
||||
<div class="brand-info">
|
||||
<div class="brand-icon cafe">
|
||||
<ion-icon name="cafe-outline"></ion-icon>
|
||||
</div>
|
||||
<div class="title-wrap">
|
||||
<h3>BERESHIT</h3>
|
||||
<p class="category">Cafetería</p>
|
||||
</div>
|
||||
</div>
|
||||
<ion-badge color="warning" class="discount-badge">10% OFF</ion-badge>
|
||||
</div>
|
||||
<p class="benefit-desc">10% de descuento en productos (excluye promociones vigentes).</p>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-row">
|
||||
<ion-icon name="location-outline"></ion-icon>
|
||||
<span>Tte. Gral. Perón 893, CABA <em>(a metros de la sede)</em></span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<ion-icon name="time-outline"></ion-icon>
|
||||
<span>Lun a Vie de 8 a 20 h • Sábados de 8 a 16 h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAFÉ & CHOCOLATE -->
|
||||
<div class="benefit-card">
|
||||
<div class="card-top">
|
||||
<div class="brand-info">
|
||||
<div class="brand-icon cafe">
|
||||
<ion-icon name="restaurant-outline"></ion-icon>
|
||||
</div>
|
||||
<div class="title-wrap">
|
||||
<h3>Café & Chocolate</h3>
|
||||
<p class="category">Cafetería & Delicatessen</p>
|
||||
</div>
|
||||
</div>
|
||||
<ion-badge color="warning" class="discount-badge">10% OFF</ion-badge>
|
||||
</div>
|
||||
<p class="benefit-desc">10% de descuento en productos (excluye promociones vigentes).</p>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-row">
|
||||
<ion-icon name="location-outline"></ion-icon>
|
||||
<span>Bartolomé Mitre 807, CABA</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<ion-icon name="time-outline"></ion-icon>
|
||||
<span>Lun a Vie de 8 a 19 h • Sábados de 8 a 14 h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AL BUEN TALLARÍN DE PABLO -->
|
||||
<div class="benefit-card">
|
||||
<div class="card-top">
|
||||
<div class="brand-info">
|
||||
<div class="brand-icon pasta">
|
||||
<ion-icon name="pizza-outline"></ion-icon>
|
||||
</div>
|
||||
<div class="title-wrap">
|
||||
<h3>Al Buen Tallarín de Pablo</h3>
|
||||
<p class="category">Fábrica de Pastas</p>
|
||||
</div>
|
||||
</div>
|
||||
<ion-badge color="danger" class="discount-badge">10% OFF</ion-badge>
|
||||
</div>
|
||||
<p class="benefit-desc">10% de descuento en productos (excluye promociones vigentes).</p>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-row">
|
||||
<ion-icon name="location-outline"></ion-icon>
|
||||
<span>Tucumán 1596, CABA</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<ion-icon name="time-outline"></ion-icon>
|
||||
<span>Mar a Vie de 8 a 19 h • Sáb y Dom de 9:30 a 14:30 h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ATLAS COMERCIAL -->
|
||||
<div class="benefit-card">
|
||||
<div class="card-top">
|
||||
<div class="brand-info">
|
||||
<div class="brand-icon libreria">
|
||||
<ion-icon name="book-outline"></ion-icon>
|
||||
</div>
|
||||
<div class="title-wrap">
|
||||
<h3>Atlas Comercial</h3>
|
||||
<p class="category">Librería & Papelería</p>
|
||||
</div>
|
||||
</div>
|
||||
<ion-badge color="primary" class="discount-badge">10% OFF</ion-badge>
|
||||
</div>
|
||||
<p class="benefit-desc">10% de descuento en productos (excluye promociones vigentes).</p>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-row">
|
||||
<ion-icon name="location-outline"></ion-icon>
|
||||
<span>Bartolomé Mitre 839, CABA</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<ion-icon name="time-outline"></ion-icon>
|
||||
<span>Lun a Vie de 9 a 18 h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FLUIRE STUDIO -->
|
||||
<div class="benefit-card">
|
||||
<div class="card-top">
|
||||
<div class="brand-info">
|
||||
<div class="brand-icon pilates">
|
||||
<ion-icon name="body-outline"></ion-icon>
|
||||
</div>
|
||||
<div class="title-wrap">
|
||||
<h3>Fluire Studio</h3>
|
||||
<p class="category">Pilates & Salud</p>
|
||||
</div>
|
||||
</div>
|
||||
<ion-badge color="tertiary" class="discount-badge">Hasta 20% OFF</ion-badge>
|
||||
</div>
|
||||
<p class="benefit-desc">
|
||||
10% de descuento abonando 1 mes de servicio y 20% de descuento abonando 4 meses de servicio.
|
||||
</p>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-row">
|
||||
<ion-icon name="location-outline"></ion-icon>
|
||||
<span>Esmeralda 135, piso 4 B, CABA</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<ion-icon name="mail-outline"></ion-icon>
|
||||
<span>Contacto: fluire.studio.ba@gmail.com</span>
|
||||
</div>
|
||||
</div>
|
||||
<ion-button expand="block" fill="outline" color="tertiary" class="action-btn"
|
||||
href="mailto:fluire.studio.ba@gmail.com?subject=Consulta%20Beneficio%20Universidad%20de%20la%20Ciudad">
|
||||
<ion-icon name="mail-outline" slot="start"></ion-icon>
|
||||
Contactar por Email
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<!-- SOY ESTUDIANTE -->
|
||||
<div class="benefit-card">
|
||||
<div class="card-top">
|
||||
<div class="brand-info">
|
||||
<div class="brand-icon travel">
|
||||
<ion-icon name="bus-outline"></ion-icon>
|
||||
</div>
|
||||
<div class="title-wrap">
|
||||
<h3>SOY Estudiante</h3>
|
||||
<p class="category">Transporte de Larga Distancia</p>
|
||||
</div>
|
||||
</div>
|
||||
<ion-badge color="secondary" class="discount-badge">Descuento Nacional</ion-badge>
|
||||
</div>
|
||||
<p class="benefit-desc">
|
||||
Obtené descuentos en pasajes de micro a todo el país a través del portal de Central de Pasajes.
|
||||
</p>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-row">
|
||||
<ion-icon name="checkmark-circle-outline"></ion-icon>
|
||||
<span>Entrá a <b>Perfil</b>, generá tu Certificado de Alumno Regular y subilo a <a href="https://soyestudiante.com.ar/" target="_blank">soyestudiante.com.ar</a>.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="footer-info ion-text-center">
|
||||
<p><small>Presentá tu credencial digital de Aulas UNICABA en los comercios adheridos para acceder a los descuentos.</small></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ion-content>
|
||||
@@ -0,0 +1,238 @@
|
||||
.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; }
|
||||
}
|
||||
}
|
||||
|
||||
/* --- ESTILOS DE BENEFICIOS --- */
|
||||
.benefits-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.benefit-card {
|
||||
background: white;
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.06);
|
||||
border: 1px solid rgba(0,0,0,0.04);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&.featured {
|
||||
border: 1.5px solid var(--ion-color-primary);
|
||||
background: linear-gradient(180deg, #ffffff 0%, rgba(var(--ion-color-primary-rgb), 0.02) 100%);
|
||||
box-shadow: 0 6px 20px rgba(var(--ion-color-primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.brand-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.brand-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--ion-color-primary-rgb), 0.1);
|
||||
color: var(--ion-color-primary);
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.cafe { background: #fff3e0; color: #e65100; }
|
||||
&.gym { background: #e8f5e9; color: #2e7d32; }
|
||||
&.pilates { background: #ede7f6; color: #512da8; }
|
||||
&.pasta { background: #fbe9e7; color: #d84315; }
|
||||
&.libreria { background: #e1f5fe; color: #0288d1; }
|
||||
&.travel { background: #e3f2fd; color: #1565c0; }
|
||||
}
|
||||
|
||||
.title-wrap {
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
.category {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--ion-color-medium);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.discount-badge {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
}
|
||||
|
||||
.benefit-desc {
|
||||
font-size: 0.92rem;
|
||||
color: var(--ion-color-dark-shade);
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #e0e0e0;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 0.84rem;
|
||||
color: var(--ion-color-medium-shade);
|
||||
line-height: 1.4;
|
||||
|
||||
ion-icon {
|
||||
font-size: 16px;
|
||||
margin-top: 2px;
|
||||
color: var(--ion-color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inclusion-box {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin: 10px 0;
|
||||
border-left: 3px solid var(--ion-color-success);
|
||||
|
||||
h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
li {
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 4px;
|
||||
color: #475569;
|
||||
&:last-child { margin-bottom: 0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
--border-radius: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 0.88rem;
|
||||
margin-top: 6px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
margin-top: 30px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { HomePage } from './home.page';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomePage,
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class HomePageRoutingModule {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HomePage } from './home.page';
|
||||
|
||||
import { HomePageRoutingModule } from './home-routing.module';
|
||||
import { CredencialModalComponent } from '../components/credencial-modal/credencial-modal.component';
|
||||
import { EventoModalComponent } from '../components/evento-modal/evento-modal.component';
|
||||
|
||||
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
HomePageRoutingModule,
|
||||
CredencialModalComponent,
|
||||
EventoModalComponent
|
||||
],
|
||||
declarations: [HomePage]
|
||||
})
|
||||
export class HomePageModule {}
|
||||
@@ -0,0 +1,231 @@
|
||||
<ion-header [translucent]="true">
|
||||
<ion-toolbar color="secondary">
|
||||
<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 (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">
|
||||
<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>
|
||||
</ion-toolbar>
|
||||
|
||||
<ion-toolbar color="light" class="ion-no-padding">
|
||||
<div class="view-segment">
|
||||
<ion-segment [(ngModel)]="viewMode" (ionChange)="onViewModeChange()" color="primary">
|
||||
<ion-segment-button value="favoritos">Favoritos</ion-segment-button>
|
||||
<ion-segment-button value="todas">Cartelera</ion-segment-button>
|
||||
<ion-segment-button value="plan">Mi Carrera</ion-segment-button>
|
||||
</ion-segment>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
|
||||
<ion-toolbar color="light" class="ion-no-padding" *ngIf="viewMode === 'todas'">
|
||||
<div class="filter-section">
|
||||
<ion-searchbar placeholder="Buscar materia, aula o código..." (ionInput)="onSearchChange($event)"
|
||||
[debounce]="250"></ion-searchbar>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<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"
|
||||
refreshingText="Actualizando..."></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<!-- Banner: Próxima Clase -->
|
||||
<div class="proxima-clase-container" *ngIf="viewMode === 'favoritos' && proximaClase && !loading">
|
||||
<ion-card class="proxima-clase-card" [class.urgente]="proximaClase.diffMs > 0 && proximaClase.diffMs <= 1800000">
|
||||
<ion-card-content (click)="verAgendaMateria(proximaClase.item)">
|
||||
<div class="proxima-header">
|
||||
<div class="proxima-label">
|
||||
<ion-icon name="time-outline"></ion-icon>
|
||||
<span>{{ proximaClase.label }}</span>
|
||||
</div>
|
||||
<ion-chip [color]="countdownColor(proximaClase.diffMs)" class="countdown-chip"
|
||||
[class.pulse-chip]="proximaClase.diffMs > 0 && proximaClase.diffMs <= 1800000">
|
||||
<ion-icon name="alarm-outline"></ion-icon>
|
||||
<ion-label>{{ formatCountdown(proximaClase.diffMs) }}</ion-label>
|
||||
</ion-chip>
|
||||
</div>
|
||||
|
||||
<p class="proxima-asignatura">{{ proximaClase.item.asignatura }}</p>
|
||||
<div class="proxima-detalles">
|
||||
<span class="proxima-aula">
|
||||
<ion-icon name="location-outline"></ion-icon>
|
||||
Aula {{ proximaClase.item.aula }}
|
||||
</span>
|
||||
<span class="proxima-hora">
|
||||
<ion-icon name="calendar-outline"></ion-icon>
|
||||
{{ proximaClase.item.dia }} {{ proximaClase.item.horario }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</div>
|
||||
|
||||
<div class="loading-container" *ngIf="loading">
|
||||
<ion-spinner name="crescent" color="primary"></ion-spinner>
|
||||
<p>Cargando cartelera...</p>
|
||||
</div>
|
||||
|
||||
<div class="error-state" *ngIf="!loading && errorMsg">
|
||||
<p>{{ errorMsg }}</p>
|
||||
</div>
|
||||
|
||||
<ion-list *ngIf="!loading && !errorMsg && horariosFiltrados.length > 0 && viewMode !== 'plan'">
|
||||
<ion-card *ngFor="let item of horariosFiltrados" [class.highlight-card]="item.codigo === highlightedCodigo"
|
||||
class="subject-card">
|
||||
|
||||
<ion-card-header>
|
||||
<div class="card-header-flex">
|
||||
<div class="header-titles">
|
||||
<ion-card-subtitle color="primary">{{ item.carrera }}</ion-card-subtitle>
|
||||
<ion-card-title>{{ item.asignatura }}</ion-card-title>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<!-- Compartir siempre visible -->
|
||||
<ion-icon class="share-icon" name="share-social-outline" color="medium"
|
||||
(click)="shareItem(item)"></ion-icon>
|
||||
|
||||
<!-- Icono condicional: Hamburguesa en favoritos, Estrella en cartelera -->
|
||||
<ion-icon class="menu-icon"
|
||||
[name]="viewMode === 'favoritos' ? 'menu-outline' : (isFavorito(item) ? 'star' : 'star-outline')"
|
||||
[color]="(viewMode === 'todas' && isFavorito(item)) ? 'warning' : 'medium'"
|
||||
(click)="openMateriaActions(item)"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Indicador de Próximo Evento (Agenda) -->
|
||||
<div class="evento-badge-container" *ngIf="getProximoEvento(item.codigo) as proximo">
|
||||
<ion-badge [color]="isEventoUrgente(proximo.fecha) ? 'danger' : 'secondary'" class="evento-badge">
|
||||
<ion-icon [name]="proximo.tipo === 'TP' ? 'document-text-outline' : 'calendar-outline'"></ion-icon>
|
||||
{{ proximo.tipo }}: {{ formatFechaEvento(proximo.fecha) }}
|
||||
</ion-badge>
|
||||
</div>
|
||||
|
||||
</ion-card-header>
|
||||
<ion-card-content (click)="verAgendaMateria(item)">
|
||||
<p><strong>Código:</strong> {{ item.codigo }}</p>
|
||||
<div class="aula-container">
|
||||
<span class="aula-tag">{{ item.aula }}</span>
|
||||
</div>
|
||||
<p class="schedule-text">{{ item.dia }} - {{ item.horario }}</p>
|
||||
<div *ngIf="item.link" class="link-container">
|
||||
<ion-button fill="outline" color="primary" size="small" expand="block"
|
||||
(click)="openLink(item.link!); $event.stopPropagation()">
|
||||
<ion-icon slot="start" name="videocam-outline"></ion-icon>
|
||||
Ingresar a Clase Virtual
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-card-content>
|
||||
|
||||
</ion-card>
|
||||
</ion-list>
|
||||
|
||||
<div class="empty-state" *ngIf="!loading && !errorMsg && horariosFiltrados.length === 0 && viewMode !== 'plan'">
|
||||
<p>No se encontraron horarios para la selección actual.</p>
|
||||
</div>
|
||||
|
||||
<!-- Vista: Mi Carrera (Plan de Estudios) -->
|
||||
<div *ngIf="viewMode === 'plan'" class="ion-padding content-plan">
|
||||
<ion-card class="carrera-selector-card">
|
||||
<ion-card-content>
|
||||
<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;">
|
||||
</div>
|
||||
</div>
|
||||
<p class="selector-desc">Selecciona tu carrera para guardar tu avance y ver el recorrido sugerido, toca materias
|
||||
para marcarlas como aprobadas o en curso.</p>
|
||||
<ion-item lines="none" class="carrera-item">
|
||||
<ion-icon name="school-outline" slot="start" color="primary"></ion-icon>
|
||||
<ion-select placeholder="Elegí una carrera" [(ngModel)]="carreraSeleccionada" interface="popover">
|
||||
<ion-select-option value="tecnologias">Lic. en Tecnologías Digitales</ion-select-option>
|
||||
<ion-select-option value="datos">Lic. en Ciencia de Datos</ion-select-option>
|
||||
<ion-select-option value="videojuegos">Lic. en Des. de Videojuegos</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<div *ngIf="carreraSeleccionada" class="tabla-recorrido-container fade-in">
|
||||
<table class="recorrido-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>1° Cuat.</th>
|
||||
<th>2° Cuat.</th>
|
||||
<th>3° Cuat.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let row of getRecorridoData()">
|
||||
<td>
|
||||
<div class="materia-cell" [class.aprobada]="isAprobada(row.c1.name)"
|
||||
[class.encurso]="isEnCurso(row.c1.name)" [class.optativa-cell]="row.c1.isOptativa"
|
||||
(click)="handleCellClick(row.c1)">
|
||||
<span class="m-name">{{ abreviarMateria(row.c1.name) }}</span>
|
||||
<ion-badge *ngIf="getSiuMateriaParaCelda(row.c1.name) as siuMat"
|
||||
[ngClass]="'custom-badge-' + getNotaColor(siuMat.nota)" class="siu-cell-badge">
|
||||
{{ getSiuBadgeText(siuMat) }}
|
||||
</ion-badge>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="materia-cell" [class.aprobada]="isAprobada(row.c2.name)"
|
||||
[class.encurso]="isEnCurso(row.c2.name)" [class.optativa-cell]="row.c2.isOptativa"
|
||||
(click)="handleCellClick(row.c2)">
|
||||
<span class="m-name">{{ abreviarMateria(row.c2.name) }}</span>
|
||||
<ion-badge *ngIf="getSiuMateriaParaCelda(row.c2.name) as siuMat"
|
||||
[ngClass]="'custom-badge-' + getNotaColor(siuMat.nota)" class="siu-cell-badge">
|
||||
{{ getSiuBadgeText(siuMat) }}
|
||||
</ion-badge>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="materia-cell" [class.aprobada]="isAprobada(row.c3.name)"
|
||||
[class.encurso]="isEnCurso(row.c3.name)" [class.optativa-cell]="row.c3.isOptativa"
|
||||
(click)="handleCellClick(row.c3)">
|
||||
<span class="m-name">{{ abreviarMateria(row.c3.name) }}</span>
|
||||
<ion-badge *ngIf="getSiuMateriaParaCelda(row.c3.name) as siuMat"
|
||||
[ngClass]="'custom-badge-' + getNotaColor(siuMat.nota)" class="siu-cell-badge">
|
||||
{{ getSiuBadgeText(siuMat) }}
|
||||
</ion-badge>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- The SIU Guaraní list at the bottom was removed per user request -->
|
||||
</div>
|
||||
|
||||
|
||||
</ion-content>
|
||||
|
||||
<ion-footer class="ion-no-border">
|
||||
<ion-toolbar class="footer-toolbar">
|
||||
<div class="modern-footer-btn" (click)="openMap()" title="Abrir en Mapas">
|
||||
<ion-icon name="location" class="location-icon"></ion-icon>
|
||||
<div class="footer-text-column">
|
||||
<span class="footer-title">U de la Ciudad</span>
|
||||
<span class="footer-address">Tte. Gral. J.D. Perón 802 • CABA</span>
|
||||
</div>
|
||||
<ion-icon name="navigate-circle" class="nav-icon"></ion-icon>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,743 @@
|
||||
.filter-section {
|
||||
background: var(--ion-background-color);
|
||||
padding: 5px 0;
|
||||
|
||||
ion-item {
|
||||
--background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 50px;
|
||||
color: var(--ion-color-medium);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
color: var(--ion-color-medium);
|
||||
}
|
||||
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 30px 20px;
|
||||
color: var(--ion-color-danger);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
// Header styles
|
||||
.header-container-flex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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: 38px;
|
||||
background: white;
|
||||
padding: 4px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Footer replica styles
|
||||
.footer-toolbar {
|
||||
--background: var(--ion-background-color);
|
||||
--border-width: 0;
|
||||
padding: 8px 14px;
|
||||
padding-bottom: calc(8px + var(--ion-safe-area-bottom, 0px));
|
||||
}
|
||||
|
||||
.modern-footer-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, var(--ion-color-primary), var(--ion-color-secondary));
|
||||
border-radius: 16px;
|
||||
padding: 12px 16px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(var(--ion-color-primary-rgb), 0.3);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
box-shadow: 0 2px 8px rgba(var(--ion-color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.location-icon {
|
||||
font-size: 24px;
|
||||
margin-right: 12px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer-text-column {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.footer-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.footer-address {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 28px;
|
||||
opacity: 0.95;
|
||||
margin-left: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.view-segment {
|
||||
background: var(--ion-background-color);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.card-header-flex {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
.header-titles {
|
||||
flex: 1;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.fav-icon {
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.aula-container {
|
||||
margin: 15px 0;
|
||||
|
||||
.aula-tag {
|
||||
color: #682cad;
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
ion-card {
|
||||
margin: 8px 10px;
|
||||
|
||||
ion-card-header {
|
||||
padding: 10px 14px 0;
|
||||
|
||||
ion-card-title {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
ion-card-content {
|
||||
padding: 10px 14px 14px;
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.aula-container {
|
||||
margin: 8px 0;
|
||||
|
||||
.aula-tag {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ion-card.subject-card {
|
||||
margin: 8px 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 0.1s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.share-icon,
|
||||
.menu-icon {
|
||||
font-size: 1.6rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.link-container {
|
||||
margin-top: 10px;
|
||||
|
||||
ion-button {
|
||||
--border-radius: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-text {
|
||||
color: #7B2049;
|
||||
/* Secondary Uva color */
|
||||
font-size: 0.98rem !important;
|
||||
font-weight: bold !important;
|
||||
margin-top: 8px !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.highlight-card {
|
||||
border: 2px solid var(--ion-color-secondary) !important;
|
||||
animation: pulse-border 1s alternate infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
from {
|
||||
box-shadow: 0 0 5px var(--ion-color-secondary);
|
||||
}
|
||||
|
||||
to {
|
||||
box-shadow: 0 0 20px var(--ion-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode overwrites for custom colored text */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.aula-container .aula-tag {
|
||||
color: #ae7fff;
|
||||
/* Lighter vivid purple for dark background */
|
||||
}
|
||||
|
||||
.schedule-text {
|
||||
color: #ff8ab6 !important;
|
||||
/* Lighter magenta for dark background */
|
||||
}
|
||||
|
||||
.highlight-card {
|
||||
border-color: var(--ion-color-primary) !important;
|
||||
animation: pulse-border-dark 1s alternate infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-border-dark {
|
||||
from {
|
||||
box-shadow: 0 0 5px var(--ion-color-primary);
|
||||
}
|
||||
|
||||
to {
|
||||
box-shadow: 0 0 20px var(--ion-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.proxima-clase-card {
|
||||
background: linear-gradient(135deg, #1e2a4a, #1a1f3c) !important;
|
||||
border-color: rgba(var(--ion-color-primary-rgb), 0.6) !important;
|
||||
|
||||
|
||||
|
||||
.proxima-label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.proxima-asignatura {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.proxima-detalles {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
.evento-badge {
|
||||
--background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||
color: var(--ion-color-secondary-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hitos Académicos (Agenda) ──────────────────────────────────────
|
||||
|
||||
.evento-badge-container {
|
||||
margin-top: 5px;
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
|
||||
.evento-badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
|
||||
ion-icon {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── Banner: Próxima Clase ───────────────────────────────────────────
|
||||
|
||||
.proxima-clase-container {
|
||||
padding: 8px 12px 0;
|
||||
}
|
||||
|
||||
.proxima-clase-card {
|
||||
margin: 0;
|
||||
border-radius: 14px;
|
||||
border: 1.5px solid rgba(var(--ion-color-primary-rgb), 0.35);
|
||||
background: linear-gradient(135deg, #eef2ff, #f5f0ff);
|
||||
box-shadow: 0 4px 18px rgba(var(--ion-color-primary-rgb), 0.15);
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
|
||||
ion-card-content {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
&.urgente {
|
||||
border-color: rgba(var(--ion-color-danger-rgb), 0.5);
|
||||
background: linear-gradient(135deg, #fff0f3, #ffe8ed);
|
||||
box-shadow: 0 4px 18px rgba(var(--ion-color-danger-rgb), 0.18);
|
||||
animation: pulse-urgente 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-urgente {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 4px 18px rgba(var(--ion-color-danger-rgb), 0.15);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 4px 28px rgba(var(--ion-color-danger-rgb), 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.proxima-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.proxima-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--ion-color-medium);
|
||||
|
||||
ion-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.countdown-chip {
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
height: 28px;
|
||||
--padding-start: 10px;
|
||||
--padding-end: 10px;
|
||||
|
||||
ion-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-chip {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.75;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-chip {
|
||||
animation: pulse-chip 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.proxima-asignatura {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.proxima-detalles {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.88rem;
|
||||
color: var(--ion-color-medium-shade);
|
||||
font-weight: 500;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Recorrido Académico (Plan de Estudios) ───────────────────────────
|
||||
|
||||
/* --- Vista: Mi Carrera (Plan de Estudios) --- */
|
||||
|
||||
.content-plan {
|
||||
background: var(--ion-color-light);
|
||||
}
|
||||
|
||||
.carrera-selector-card {
|
||||
margin: 10px 0 20px 0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
|
||||
ion-card-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.selector-title {
|
||||
display: block;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.selector-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--ion-color-medium);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.carrera-item {
|
||||
--background: var(--ion-color-light);
|
||||
border-radius: 8px;
|
||||
--padding-start: 10px;
|
||||
--inner-padding-end: 10px;
|
||||
|
||||
ion-select {
|
||||
font-weight: 600;
|
||||
color: var(--ion-color-primary);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.recorrido-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.70rem;
|
||||
/* Fuente más chica para nombres largos */
|
||||
|
||||
th {
|
||||
background: var(--ion-color-primary);
|
||||
color: white;
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 4px;
|
||||
vertical-align: top;
|
||||
width: 33.33%;
|
||||
}
|
||||
|
||||
.materia-cell {
|
||||
padding: 6px 4px;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 0.65;
|
||||
/* Reducimos 15% el alto respecto al ancho */
|
||||
background: var(--ion-color-light);
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1.5px solid transparent;
|
||||
|
||||
.m-name {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.siu-cell-badge {
|
||||
margin-top: 4px;
|
||||
font-size: 0.75rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
|
||||
|
||||
&.custom-badge-success {
|
||||
--background: rgba(255, 255, 255, 0.95);
|
||||
color: #a31c62;
|
||||
/* Rosa oscuro legible sobre blanco */
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&.custom-badge-warning {
|
||||
--background: rgba(255, 255, 255, 0.95);
|
||||
color: #0b5f7e;
|
||||
/* Azul/Cyan oscuro legible sobre blanco */
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&.custom-badge-danger {
|
||||
--background: rgba(var(--ion-color-danger-rgb), 0.15);
|
||||
color: var(--ion-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
&.aprobada {
|
||||
background: #682cad;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 6px rgba(104, 44, 173, 0.3);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.encurso {
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.15); // Subtono Cyan/Celeste
|
||||
color: var(--ion-color-secondary);
|
||||
font-weight: 600;
|
||||
border: 1.5px solid var(--ion-color-secondary);
|
||||
}
|
||||
|
||||
&.optativa-cell:not(.aprobada):not(.encurso) {
|
||||
border: 1.5px dashed var(--ion-color-medium);
|
||||
color: var(--ion-color-primary);
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instruccion-marcar {
|
||||
text-align: center;
|
||||
color: var(--ion-color-medium);
|
||||
margin-top: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// ─── SIU Guaraní ───────────────────────────────────────────
|
||||
|
||||
.siu-container {
|
||||
padding: 10px 0;
|
||||
|
||||
.siu-header {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.ultima-sync {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.siu-card {
|
||||
margin: 10px 0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||
|
||||
ion-card-header {
|
||||
padding: 12px 16px 4px;
|
||||
|
||||
ion-card-title {
|
||||
color: var(--ion-color-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.siu-card-content {
|
||||
padding: 10px 16px 16px;
|
||||
|
||||
ion-badge {
|
||||
font-size: 0.9rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.siu-login-modal {
|
||||
--border-radius: 16px 16px 0 0;
|
||||
|
||||
.siu-logo-container {
|
||||
padding-top: 20px;
|
||||
|
||||
h2 {
|
||||
font-weight: 800;
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.siu-form-list {
|
||||
background: transparent;
|
||||
|
||||
ion-item {
|
||||
--background: rgba(var(--ion-color-light-rgb), 0.5);
|
||||
--border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
--padding-start: 12px;
|
||||
|
||||
ion-input {
|
||||
margin-left: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ion-button[expand="block"] {
|
||||
margin-top: 20px;
|
||||
height: 48px;
|
||||
--border-radius: 12px;
|
||||
font-weight: bold;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.siu-card {
|
||||
background: var(--ion-color-step-100);
|
||||
}
|
||||
|
||||
.siu-login-modal {
|
||||
.siu-form-list ion-item {
|
||||
--background: var(--ion-color-step-100);
|
||||
}
|
||||
|
||||
.siu-info {
|
||||
background: var(--ion-color-step-100) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mejorar el contraste de los botones de Optativa en modo oscuro */
|
||||
.recorrido-table .materia-cell.optativa-cell:not(.aprobada):not(.encurso) {
|
||||
border-color: rgba(255, 255, 255, 0.45);
|
||||
color: #ff8ab6;
|
||||
/* Lighter magenta for dark background */
|
||||
}
|
||||
|
||||
/* Mejorar íconos y bordes de encabezado (botones SIU/Guardar) */
|
||||
ion-button.header-btn {
|
||||
--border-width: 1.5px;
|
||||
}
|
||||
|
||||
ion-button.header-btn[color="primary"] {
|
||||
--color: #ff8ab6 !important;
|
||||
--border-color: #ff8ab6 !important;
|
||||
}
|
||||
|
||||
ion-button.header-btn[color="tertiary"] {
|
||||
--color: #ae7fff !important;
|
||||
--border-color: #ae7fff !important;
|
||||
}
|
||||
|
||||
ion-button.header-btn[color="success"] {
|
||||
--color: #68f4a1 !important;
|
||||
--border-color: #68f4a1 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
|
||||
import { HomePage } from './home.page';
|
||||
|
||||
describe('HomePage', () => {
|
||||
let component: HomePage;
|
||||
let fixture: ComponentFixture<HomePage>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [HomePage],
|
||||
imports: [IonicModule.forRoot()]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HomePage);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
import { PerfilPage } from './perfil.page';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: PerfilPage
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class PerfilPageRoutingModule {}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { PerfilPageRoutingModule } from './perfil-routing.module';
|
||||
import { PerfilPage } from './perfil.page';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
PerfilPageRoutingModule
|
||||
],
|
||||
declarations: [PerfilPage]
|
||||
})
|
||||
export class PerfilPageModule {}
|
||||
@@ -0,0 +1,214 @@
|
||||
<ion-header [translucent]="true" class="ion-no-border">
|
||||
<ion-toolbar color="secondary">
|
||||
<ion-buttons slot="start">
|
||||
<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>
|
||||
|
||||
<ion-content [fullscreen]="true" color="light">
|
||||
<div class="profile-header-gradient">
|
||||
<div class="user-avatar-container">
|
||||
<div class="avatar-circle">
|
||||
<img *ngIf="authState?.userPhoto" [src]="authState?.userPhoto" class="user-photo-profile" />
|
||||
<ion-icon *ngIf="!authState?.userPhoto" name="person-circle-outline"></ion-icon>
|
||||
</div>
|
||||
<h2 *ngIf="authState?.userName">{{ authState?.userName }}</h2>
|
||||
<h2 *ngIf="!authState?.userName">Gestión de Cuentas</h2>
|
||||
<p>Unificá tus credenciales de SIU y Campus</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-container">
|
||||
<!-- SIU Guaraní Card -->
|
||||
<ion-card class="auth-card" [class.connected]="authState?.siuConnected">
|
||||
<ion-card-header>
|
||||
<div class="card-title-row">
|
||||
<ion-icon name="school" color="primary" class="service-icon"></ion-icon>
|
||||
<ion-card-title>SIU Guaraní</ion-card-title>
|
||||
<ion-badge *ngIf="authState?.siuConnected" color="primary" class="status-badge">Conectado</ion-badge>
|
||||
<ion-badge *ngIf="!authState?.siuConnected" color="medium" class="status-badge">Sin Vincular</ion-badge>
|
||||
</div>
|
||||
<ion-card-subtitle>Historia Académica y Cartelera Personalizada</ion-card-subtitle>
|
||||
</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>
|
||||
<ion-list lines="inset" class="auth-form">
|
||||
<ion-item>
|
||||
<ion-icon name="person-outline" slot="start" color="medium"></ion-icon>
|
||||
<ion-input type="text" placeholder="DNI (Sin puntos)" [(ngModel)]="siuDni" clearInput></ion-input>
|
||||
</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-button slot="end" fill="clear" (click)="showSiuPass = !showSiuPass" color="medium">
|
||||
<ion-icon [name]="showSiuPass ? 'eye-off-outline' : 'eye-outline'"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
<ion-button expand="block" (click)="loginSiu()" class="ion-margin-top btn-connect">
|
||||
<ion-icon name="log-in-outline" slot="start"></ion-icon>
|
||||
Sincronizar SIU
|
||||
</ion-button>
|
||||
<p class="error-text" *ngIf="authState?.siuError">{{ authState?.siuError }}</p>
|
||||
</ion-card-content>
|
||||
|
||||
<ion-card-content *ngIf="authState?.siuConnected">
|
||||
<div class="connected-info">
|
||||
<ion-icon name="checkmark-circle" color="primary"></ion-icon>
|
||||
<div class="text">
|
||||
<strong>Cuenta Vinculada</strong>
|
||||
<span>DNI: {{ siuDni }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ion-button expand="block" fill="outline" color="primary" (click)="generateCertificate()" class="btn-cert">
|
||||
<ion-icon name="document-text-outline" slot="start"></ion-icon>
|
||||
Certificado de Alumno Regular
|
||||
</ion-button>
|
||||
<ion-button expand="block" fill="clear" color="danger" (click)="logoutSiu()" class="btn-logout">
|
||||
<ion-icon name="log-out-outline" slot="start"></ion-icon>
|
||||
Cerrar sesión SIU
|
||||
</ion-button>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<!-- Campus UNICABA (Moodle) Card -->
|
||||
<ion-card class="auth-card" [class.connected]="authState?.moodleConnected">
|
||||
<ion-card-header>
|
||||
<div class="card-title-row">
|
||||
<ion-icon name="book" color="secondary" class="service-icon"></ion-icon>
|
||||
<ion-card-title>Campus UNICABA</ion-card-title>
|
||||
<ion-badge *ngIf="authState?.moodleConnected" color="primary" class="status-badge">Conectado</ion-badge>
|
||||
<ion-badge *ngIf="!authState?.moodleConnected" color="medium" class="status-badge">Sin Vincular</ion-badge>
|
||||
</div>
|
||||
<ion-card-subtitle>Credencial Digital y Aulas Virtuales</ion-card-subtitle>
|
||||
</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>
|
||||
<ion-list lines="inset" class="auth-form">
|
||||
<ion-item>
|
||||
<ion-icon name="person-outline" slot="start" color="medium"></ion-icon>
|
||||
<ion-input type="text" placeholder="DNI de Campus" [(ngModel)]="moodleDni" clearInput></ion-input>
|
||||
</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-button slot="end" fill="clear" (click)="showMoodlePass = !showMoodlePass" color="medium">
|
||||
<ion-icon [name]="showMoodlePass ? 'eye-off-outline' : 'eye-outline'"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
<ion-button expand="block" color="secondary" (click)="loginMoodle()" class="ion-margin-top btn-connect">
|
||||
<ion-icon name="card-outline" slot="start"></ion-icon>
|
||||
Vincular Credencial
|
||||
</ion-button>
|
||||
<p class="error-text" *ngIf="authState?.moodleError">{{ authState?.moodleError }}</p>
|
||||
</ion-card-content>
|
||||
|
||||
<ion-card-content *ngIf="authState?.moodleConnected">
|
||||
<div class="connected-info">
|
||||
<ion-icon name="checkmark-circle" color="secondary"></ion-icon>
|
||||
<div class="text">
|
||||
<strong>Cuenta Vinculada</strong>
|
||||
<span>DNI: {{ moodleDni }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ion-button expand="block" fill="clear" color="danger" (click)="logoutMoodle()" class="btn-logout">
|
||||
<ion-icon name="log-out-outline" slot="start"></ion-icon>
|
||||
Cerrar sesión Campus
|
||||
</ion-button>
|
||||
</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>
|
||||
|
||||
<!-- Beneficios del Estudiante -->
|
||||
<div class="section-header ion-padding-horizontal">
|
||||
<h3>Beneficios del Estudiante</h3>
|
||||
<p>Aprovechá convenios exclusivos por ser parte de UNICABA.</p>
|
||||
</div>
|
||||
|
||||
<ion-row class="benefits-row">
|
||||
<ion-col size="6">
|
||||
<ion-card class="benefit-card" (click)="showBenefitDetail('soy')">
|
||||
<div class="benefit-icon-container soy">
|
||||
<ion-icon name="school-outline"></ion-icon>
|
||||
</div>
|
||||
<ion-card-content>
|
||||
<h4>SOY Estudiante</h4>
|
||||
<p>Descuentos en pasajes de micro.</p>
|
||||
<ion-badge color="success">Activo</ion-badge>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
<ion-col size="6">
|
||||
<ion-card class="benefit-card" (click)="showBenefitDetail('google')">
|
||||
<div class="benefit-icon-container google">
|
||||
<ion-icon name="logo-google" color="primary"></ion-icon>
|
||||
</div>
|
||||
<ion-card-content>
|
||||
<h4>Google Students</h4>
|
||||
<p>Gemini PRO & Drive 1TB.</p>
|
||||
<ion-badge color="tertiary">Bonificado</ion-badge>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
|
||||
|
||||
<div class="security-info ion-padding">
|
||||
<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>
|
||||
@@ -0,0 +1,309 @@
|
||||
.profile-header-gradient {
|
||||
background: linear-gradient(135deg, var(--ion-color-secondary) 0%, var(--ion-color-primary) 100%);
|
||||
padding: 40px 20px 60px 20px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
border-bottom-left-radius: 40px;
|
||||
border-bottom-right-radius: 40px;
|
||||
box-shadow: 0 10px 30px rgba(var(--ion-color-primary-rgb), 0.3);
|
||||
|
||||
.user-avatar-container {
|
||||
.avatar-circle {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 15px auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
overflow: hidden;
|
||||
|
||||
ion-icon {
|
||||
font-size: 50px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.user-photo-profile {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 5px 0 0 0;
|
||||
opacity: 0.9;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-container {
|
||||
margin-top: -30px;
|
||||
padding: 0 16px 20px 16px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
border-radius: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.connected {
|
||||
border-left: 6px solid var(--ion-color-primary);
|
||||
}
|
||||
|
||||
ion-card-header {
|
||||
padding-bottom: 5px;
|
||||
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.service-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
ion-card-title {
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
ion-card-subtitle {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ion-color-medium);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
|
||||
ion-item {
|
||||
--padding-start: 10px;
|
||||
--inner-padding-end: 10px;
|
||||
--border-radius: 12px;
|
||||
background: var(--ion-color-light);
|
||||
margin-bottom: 8px;
|
||||
--border-color: rgba(0, 0, 0, 0.05);
|
||||
|
||||
ion-icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-connect {
|
||||
margin-top: 20px;
|
||||
--border-radius: 12px;
|
||||
font-weight: 600;
|
||||
height: 48px;
|
||||
box-shadow: 0 4px 15px rgba(var(--ion-color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.connected-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
background: rgba(var(--ion-color-primary-rgb), 0.1);
|
||||
padding: 15px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
strong {
|
||||
font-size: 1rem;
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--ion-color-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
--color: var(--ion-color-danger);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-cert {
|
||||
margin-top: 12px;
|
||||
--border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-top: 24px;
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ion-color-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.benefits-row {
|
||||
padding: 0 8px;
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.benefit-card {
|
||||
margin: 8px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 16px);
|
||||
transition: transform 0.2s;
|
||||
overflow: hidden;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.benefit-icon-container {
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.soy {
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e0e7ff 100%);
|
||||
ion-icon { font-size: 40px; color: #1a73e8; }
|
||||
}
|
||||
|
||||
&.google {
|
||||
background: linear-gradient(135deg, #fff5f5 0%, #ffe0e0 100%);
|
||||
ion-icon { font-size: 40px; }
|
||||
}
|
||||
}
|
||||
|
||||
ion-card-content {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
p {
|
||||
margin: 6px 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ion-color-medium);
|
||||
line-height: 1.2;
|
||||
}
|
||||
ion-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.security-info {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
opacity: 0.7;
|
||||
|
||||
ion-icon {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep .benefit-alert {
|
||||
.alert-message {
|
||||
text-align: left;
|
||||
}
|
||||
.benefit-modal-content {
|
||||
b { color: var(--ion-color-primary); }
|
||||
ol {
|
||||
padding-left: 20px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
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({
|
||||
selector: 'app-perfil',
|
||||
templateUrl: './perfil.page.html',
|
||||
styleUrls: ['./perfil.page.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class PerfilPage implements OnInit, OnDestroy {
|
||||
authState: AuthState | null = null;
|
||||
private authSub: Subscription | null = null;
|
||||
|
||||
// Form states
|
||||
siuDni: string = '';
|
||||
siuPass: string = '';
|
||||
showSiuPass: boolean = false;
|
||||
|
||||
moodleDni: string = '';
|
||||
moodlePass: string = '';
|
||||
showMoodlePass: boolean = false;
|
||||
|
||||
loading: boolean = false;
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private siuService: SiuService,
|
||||
private credencialService: CredencialService,
|
||||
private localStorageService: LocalStorageService,
|
||||
private eventosService: EventosService,
|
||||
private toastCtrl: ToastController,
|
||||
private loadingCtrl: LoadingController,
|
||||
private alertCtrl: AlertController,
|
||||
private actionSheetCtrl: ActionSheetController,
|
||||
private modalCtrl: ModalController,
|
||||
private navCtrl: NavController
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.authSub = this.authService.authState$.subscribe(state => {
|
||||
this.authState = state;
|
||||
// Pre-fill DNI if already connected
|
||||
if (state.siuConnected && !this.siuDni) this.siuDni = state.siuDni || '';
|
||||
if (state.moodleConnected && !this.moodleDni) this.moodleDni = state.moodleDni || '';
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.authSub) this.authSub.unsubscribe();
|
||||
}
|
||||
|
||||
async loginSiu() {
|
||||
if (!this.siuDni || !this.siuPass) {
|
||||
this.showToast('Por favor, ingresá DNI y Contraseña', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = await this.loadingCtrl.create({ message: 'Validando con SIU Guaraní...' });
|
||||
await loader.present();
|
||||
|
||||
try {
|
||||
const materias = await this.siuService.getAcademicHistory(this.siuDni, this.siuPass);
|
||||
await this.authService.saveSiuCredentials(this.siuDni, this.siuPass);
|
||||
|
||||
// Persistir datos en LocalStorage para que Home los vea
|
||||
const currentData = await this.localStorageService.loadData() || this.getDefaultData();
|
||||
currentData.siuMaterias = materias;
|
||||
currentData.siuLastSync = new Date().toISOString();
|
||||
|
||||
// Aplicar auto-favoritos (opcional, pero recomendado para consistencia)
|
||||
// Nota: Aquí se podría llamar a una versión compartida de autoCompletarDesdeSiu
|
||||
// Por ahora guardamos para que Home lo procese al volver
|
||||
await this.localStorageService.saveData(currentData);
|
||||
|
||||
this.showToast('¡SIU Guaraní conectado y sincronizado!', 'success');
|
||||
this.siuPass = '';
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
this.authService.setSiuError(e.message || 'Error de conexión');
|
||||
this.showToast('Error al conectar con SIU: ' + (e.message || 'Credenciales inválidas'), 'danger');
|
||||
} finally {
|
||||
await loader.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
async logoutSiu() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Cerrar Sesión en SIU',
|
||||
message: '¿Estás seguro de que deseas desconectar tu cuenta de SIU Guaraní? Se borrarán tus credenciales guardadas.',
|
||||
buttons: [
|
||||
{ text: 'Cancelar', role: 'cancel' },
|
||||
{
|
||||
text: 'Cerrar Sesión',
|
||||
role: 'destructive',
|
||||
handler: () => {
|
||||
this.authService.logoutSiu();
|
||||
this.siuDni = '';
|
||||
this.siuPass = '';
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
async loginMoodle() {
|
||||
if (!this.moodleDni || !this.moodlePass) {
|
||||
this.showToast('Por favor, ingresá DNI y Contraseña (Campus)', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = await this.loadingCtrl.create({ message: 'Validando con Campus UNICABA...' });
|
||||
await loader.present();
|
||||
|
||||
try {
|
||||
// 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
|
||||
);
|
||||
|
||||
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) {
|
||||
console.error(e);
|
||||
this.authService.setMoodleError(e.message || 'Error de conexión');
|
||||
this.showToast('Error al conectar con Campus: ' + (e.message || 'Credenciales inválidas'), 'danger');
|
||||
} finally {
|
||||
await loader.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async logoutMoodle() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Cerrar Sesión en Campus',
|
||||
message: '¿Estás seguro de que deseas desconectar tu cuenta del Campus? Se borrará tu credencial digital cacheada.',
|
||||
buttons: [
|
||||
{ text: 'Cancelar', role: 'cancel' },
|
||||
{
|
||||
text: 'Cerrar Sesión',
|
||||
role: 'destructive',
|
||||
handler: () => {
|
||||
this.authService.logoutMoodle();
|
||||
this.moodleDni = '';
|
||||
this.moodlePass = '';
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
// --- AUTOMATIZACIÓN DE CERTIFICADOS ---
|
||||
|
||||
async generateCertificate() {
|
||||
const creds = await this.authService.getSiuCredentials();
|
||||
if (!creds.dni || !creds.pass) {
|
||||
this.showToast('Debe estar conectado al SIU para generar el certificado', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Gestionando Certificado en SIU Guaraní...<br><small>Esto puede demorar unos segundos</small>',
|
||||
spinner: 'crescent'
|
||||
});
|
||||
await loader.present();
|
||||
|
||||
try {
|
||||
const result = await this.siuService.downloadCertificate(creds.dni, creds.pass);
|
||||
await this.siuService.saveAndOpenFile(result.base64, result.filename);
|
||||
|
||||
this.showToast('¡Certificado generado y abierto!', 'success');
|
||||
await Haptics.impact({ style: ImpactStyle.Heavy }).catch(() => { });
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Error de Certificado',
|
||||
message: `No se pudo automatizar la descarga: ${e.message || 'Error desconocido'}. Por favor, intentalo más tarde o desde el SIU web.`,
|
||||
buttons: ['Entendido']
|
||||
});
|
||||
await alert.present();
|
||||
} finally {
|
||||
await loader.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
async showBenefitDetail(type: 'soy' | 'google') {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: InfoModalComponent,
|
||||
componentProps: {
|
||||
selectedSegment: 'benefits'
|
||||
}
|
||||
});
|
||||
await modal.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();
|
||||
}
|
||||
|
||||
private async showToast(message: string, color: 'success' | 'warning' | 'danger') {
|
||||
const toast = await this.toastCtrl.create({
|
||||
message,
|
||||
duration: 3000,
|
||||
color,
|
||||
position: 'bottom'
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
|
||||
private getDefaultData(): AppData {
|
||||
return {
|
||||
favoritos: [],
|
||||
eventos: [],
|
||||
aprobadas: [],
|
||||
encurso: [],
|
||||
optativas: [],
|
||||
siuMaterias: [],
|
||||
siuLastSync: null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
export interface AuthState {
|
||||
siuConnected: boolean;
|
||||
moodleConnected: boolean;
|
||||
siuDni: string | null;
|
||||
moodleDni: string | null;
|
||||
siuError: string | null;
|
||||
moodleError: string | null;
|
||||
userName: string | null;
|
||||
userPhoto: string | null; // Base64
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly PREF_SIU_DNI = 'auth_siu_dni';
|
||||
private readonly PREF_SIU_PASS = 'auth_siu_pass';
|
||||
private readonly PREF_MOODLE_DNI = 'auth_moodle_dni';
|
||||
private readonly PREF_MOODLE_PASS = 'auth_moodle_pass';
|
||||
private readonly PREF_USER_NAME = 'auth_user_name';
|
||||
private readonly PREF_USER_PHOTO = 'auth_user_photo';
|
||||
|
||||
private authState = new BehaviorSubject<AuthState>({
|
||||
siuConnected: false,
|
||||
moodleConnected: false,
|
||||
siuDni: null,
|
||||
moodleDni: null,
|
||||
siuError: null,
|
||||
moodleError: null,
|
||||
userName: null,
|
||||
userPhoto: null
|
||||
});
|
||||
|
||||
public authState$ = this.authState.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.initialLoad();
|
||||
}
|
||||
|
||||
private async initialLoad() {
|
||||
const siuDni = await Preferences.get({ key: this.PREF_SIU_DNI });
|
||||
const siuPass = await Preferences.get({ key: this.PREF_SIU_PASS });
|
||||
const moodleDni = await Preferences.get({ key: this.PREF_MOODLE_DNI });
|
||||
const moodlePass = await Preferences.get({ key: this.PREF_MOODLE_PASS });
|
||||
const userName = await Preferences.get({ key: this.PREF_USER_NAME });
|
||||
const userPhoto = await Preferences.get({ key: this.PREF_USER_PHOTO });
|
||||
|
||||
this.authState.next({
|
||||
...this.authState.value,
|
||||
siuConnected: !!(siuDni.value && siuPass.value),
|
||||
moodleConnected: !!(moodleDni.value && moodlePass.value),
|
||||
siuDni: siuDni.value,
|
||||
moodleDni: moodleDni.value,
|
||||
userName: userName.value,
|
||||
userPhoto: userPhoto.value
|
||||
});
|
||||
}
|
||||
|
||||
async getSiuCredentials() {
|
||||
const dni = await Preferences.get({ key: this.PREF_SIU_DNI });
|
||||
const pass = await Preferences.get({ key: this.PREF_SIU_PASS });
|
||||
return { dni: dni.value, pass: pass.value };
|
||||
}
|
||||
|
||||
async getMoodleCredentials() {
|
||||
const dni = await Preferences.get({ key: this.PREF_MOODLE_DNI });
|
||||
const pass = await Preferences.get({ key: this.PREF_MOODLE_PASS });
|
||||
return { dni: dni.value, pass: pass.value };
|
||||
}
|
||||
|
||||
async saveSiuCredentials(dni: string, pass: string) {
|
||||
await Preferences.set({ key: this.PREF_SIU_DNI, value: dni });
|
||||
await Preferences.set({ key: this.PREF_SIU_PASS, value: pass });
|
||||
this.updateState({ siuConnected: true, siuDni: dni, siuError: null });
|
||||
}
|
||||
|
||||
async saveMoodleCredentials(dni: string, pass: string) {
|
||||
await Preferences.set({ key: this.PREF_MOODLE_DNI, value: dni });
|
||||
await Preferences.set({ key: this.PREF_MOODLE_PASS, value: pass });
|
||||
this.updateState({ moodleConnected: true, moodleDni: dni, moodleError: null });
|
||||
}
|
||||
|
||||
async saveUserProfile(name: string, photo: string | null) {
|
||||
if (name) await Preferences.set({ key: this.PREF_USER_NAME, value: name });
|
||||
if (photo) await Preferences.set({ key: this.PREF_USER_PHOTO, value: photo });
|
||||
this.updateState({ userName: name, userPhoto: photo });
|
||||
}
|
||||
|
||||
async logoutSiu() {
|
||||
await Preferences.remove({ key: this.PREF_SIU_DNI });
|
||||
await Preferences.remove({ key: this.PREF_SIU_PASS });
|
||||
this.updateState({ siuConnected: false, siuDni: null, siuError: null });
|
||||
}
|
||||
|
||||
async logoutMoodle() {
|
||||
await Preferences.remove({ key: this.PREF_MOODLE_DNI });
|
||||
await Preferences.remove({ key: this.PREF_MOODLE_PASS });
|
||||
// Al cerrar sesión de moodle, también deberíamos pensar si borramos la imagen de la credencial
|
||||
await Preferences.remove({ key: 'credencial_base64' });
|
||||
await Preferences.remove({ key: this.PREF_USER_NAME });
|
||||
await Preferences.remove({ key: this.PREF_USER_PHOTO });
|
||||
this.updateState({ moodleConnected: false, moodleDni: null, moodleError: null, userName: null, userPhoto: null });
|
||||
}
|
||||
|
||||
setSiuError(error: string | null) {
|
||||
this.updateState({ siuError: error });
|
||||
}
|
||||
|
||||
setMoodleError(error: string | null) {
|
||||
this.updateState({ moodleError: error });
|
||||
}
|
||||
|
||||
private updateState(partial: Partial<AuthState>) {
|
||||
this.authState.next({ ...this.authState.value, ...partial });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CapacitorHttp, HttpResponse } from '@capacitor/core';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
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) {}
|
||||
|
||||
async getCachedImage() {
|
||||
const img = await Preferences.get({ key: this.PREF_IMAGE });
|
||||
return img.value;
|
||||
}
|
||||
|
||||
async clearCache() {
|
||||
await Preferences.remove({ key: this.PREF_IMAGE });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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...');
|
||||
|
||||
// Login and get the authenticated session cookie
|
||||
const sessionCookie = await this.loginMoodle(dni, pass);
|
||||
|
||||
if (onStatusChange) onStatusChange('Descargando 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',
|
||||
...(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}`);
|
||||
}
|
||||
|
||||
const base64Pdf = pdfRes.data;
|
||||
const pdfData = this.base64ToUint8Array(base64Pdf);
|
||||
|
||||
if (onStatusChange) onStatusChange('Generando imagen de credencial (esto puede tardar unos segundos)...');
|
||||
|
||||
// Render PDF to Base64 Image using pdf.js
|
||||
const imgBase64 = await this.renderPdfToBase64(pdfData);
|
||||
|
||||
// Save to cache
|
||||
await Preferences.set({ key: this.PREF_IMAGE, value: imgBase64 });
|
||||
|
||||
if (onStatusChange) onStatusChange('¡Listo!');
|
||||
return imgBase64;
|
||||
} catch (e: any) {
|
||||
throw new Error(e.message || 'Error desconocido al generar la credencial.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const sessionCookie = await this.loginMoodle(dni, pass);
|
||||
return this.fetchProfileWithSession(sessionCookie);
|
||||
} catch (e) {
|
||||
console.error('Error fetching user profile:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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',
|
||||
...(sessionCookie ? { 'Cookie': sessionCookie } : {})
|
||||
}
|
||||
});
|
||||
|
||||
const parser = new DOMParser();
|
||||
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.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 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: postHeaders
|
||||
});
|
||||
|
||||
// Verify login success
|
||||
if (loginPost.data && (loginPost.data.includes('loginerrormessage') || loginPost.data.includes('Inicie sesión en su cuenta'))) {
|
||||
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 {
|
||||
const raw = window.atob(base64);
|
||||
const rawLength = raw.length;
|
||||
const array = new Uint8Array(new ArrayBuffer(rawLength));
|
||||
for (let i = 0; i < rawLength; i++) {
|
||||
array[i] = raw.charCodeAt(i);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
private async renderPdfToBase64(pdfData: Uint8Array): Promise<string> {
|
||||
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;
|
||||
|
||||
const page = await pdf.getPage(1);
|
||||
|
||||
const scale = 3.0;
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
if (!context) throw new Error('Could not create Canvas context');
|
||||
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport: viewport,
|
||||
canvas: canvas
|
||||
};
|
||||
|
||||
await page.render(renderContext).promise;
|
||||
|
||||
const dataUri = canvas.toDataURL('image/png');
|
||||
const base64 = dataUri.split(',')[1];
|
||||
|
||||
return base64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Data } from './data';
|
||||
|
||||
describe('Data', () => {
|
||||
let service: Data;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(Data);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,345 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, forkJoin, from, of } from 'rxjs';
|
||||
import { map, tap, catchError } from 'rxjs/operators';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { LocalNotifications, LocalNotificationSchema } from '@capacitor/local-notifications';
|
||||
|
||||
export interface ClaseHorario {
|
||||
codigo: string;
|
||||
asignatura: string;
|
||||
aula: string;
|
||||
carrera: string;
|
||||
horario: string;
|
||||
dia?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface EventoAcademico {
|
||||
id: string;
|
||||
materiaCodigo: string;
|
||||
tipo: 'Parcial' | 'TP' | 'Final' | 'Otro';
|
||||
fecha: string; // ISO string
|
||||
aula?: string;
|
||||
link?: string;
|
||||
notas?: string;
|
||||
}
|
||||
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DataService {
|
||||
private readonly BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv';
|
||||
private readonly CACHE_KEY = 'cache_horarios';
|
||||
private readonly CACHE_VERSION = '1.1';
|
||||
private readonly CACHE_FAVS_KEY = 'favoritos';
|
||||
|
||||
private readonly SHEETS = [
|
||||
{ name: 'Lunes', gid: '1915752353' },
|
||||
{ name: 'Martes', gid: '466778263' },
|
||||
{ name: 'Miércoles', gid: '615483345' },
|
||||
{ name: 'Jueves', gid: '682416571' },
|
||||
{ name: 'Viernes', gid: '1162906711' },
|
||||
{ name: 'Sábado', gid: '752305601' },
|
||||
];
|
||||
|
||||
constructor(private http: HttpClient) { }
|
||||
|
||||
/**
|
||||
* Obtiene horarios del servidor y actualiza la caché.
|
||||
* Si hay fallos de red, se maneja en el componente llamando a getCachedHorarios.
|
||||
*/
|
||||
public getHorarios(): Observable<ClaseHorario[]> {
|
||||
const requests = this.SHEETS.map(sheet =>
|
||||
this.http.get(`${this.BASE_CSV_URL}&gid=${sheet.gid}`, { responseType: 'text' }).pipe(
|
||||
map(csvData => this.parseCsv(csvData, sheet.name))
|
||||
)
|
||||
);
|
||||
|
||||
return forkJoin(requests).pipe(
|
||||
map(results => results.reduce((acc, val) => acc.concat(val), []))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarda los nuevos datos en caché y detecta cambios de aula para notificaciones
|
||||
*/
|
||||
public async syncWithCache(newData: ClaseHorario[], favoritos: string[]) {
|
||||
const cachedData = await this.getCachedHorarios();
|
||||
if (cachedData.length > 0) {
|
||||
this.checkRoomChanges(cachedData, newData, favoritos);
|
||||
}
|
||||
await Preferences.set({
|
||||
key: this.CACHE_KEY,
|
||||
value: JSON.stringify({
|
||||
version: this.CACHE_VERSION,
|
||||
data: newData,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
public async getCachedHorarios(): Promise<ClaseHorario[]> {
|
||||
const { value } = await Preferences.get({ key: this.CACHE_KEY });
|
||||
if (!value) return [];
|
||||
|
||||
try {
|
||||
const cached = JSON.parse(value);
|
||||
if (cached.version === this.CACHE_VERSION) {
|
||||
return cached.data || [];
|
||||
} else if (Array.isArray(cached)) {
|
||||
return cached; // Compatibilidad con caché vieja (v1.0)
|
||||
}
|
||||
return [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async getFavorites(): Promise<string[]> {
|
||||
const { value } = await Preferences.get({ key: this.CACHE_FAVS_KEY });
|
||||
if (!value) return [];
|
||||
try {
|
||||
return JSON.parse(value) || [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Programa notificaciones locales 30 minutos antes de cada clase favorita del día actual.
|
||||
* Usa IDs estables (hash del código + dia + horario) para evitar notificaciones duplicadas.
|
||||
*/
|
||||
public async scheduleClassReminders(favoriteItems: ClaseHorario[]) {
|
||||
try {
|
||||
const permStatus = await LocalNotifications.checkPermissions();
|
||||
if (permStatus.display !== 'granted') return;
|
||||
|
||||
const DIAS_ES: Record<string, number> = {
|
||||
'lunes': 1, 'martes': 2, 'miércoles': 3, 'miercoles': 3,
|
||||
'jueves': 4, 'viernes': 5, 'sábado': 6, 'sabado': 6, 'domingo': 0
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const todayDow = now.getDay(); // 0=Dom, 1=Lun...
|
||||
|
||||
const notificationsToSchedule: LocalNotificationSchema[] = [];
|
||||
const idsToCancel: number[] = [];
|
||||
|
||||
for (const item of favoriteItems) {
|
||||
if (!item.dia || !item.horario) continue;
|
||||
|
||||
const diaNorm = item.dia.trim().toLowerCase();
|
||||
const classDow = DIAS_ES[diaNorm];
|
||||
if (classDow === undefined || classDow !== todayDow) continue;
|
||||
|
||||
// Extraer primer horario de inicio, ej: "18:00 a 22:00" → "18:00"
|
||||
const timeMatch = item.horario.match(/(\d{1,2}):(\d{2})/);
|
||||
if (!timeMatch) continue;
|
||||
|
||||
const classHour = parseInt(timeMatch[1], 10);
|
||||
const classMin = parseInt(timeMatch[2], 10);
|
||||
|
||||
const classStart = new Date(now);
|
||||
classStart.setHours(classHour, classMin, 0, 0);
|
||||
|
||||
const reminderTime = new Date(classStart.getTime() - 30 * 60 * 1000);
|
||||
|
||||
// Solo programa si el recordatorio es en el futuro
|
||||
if (reminderTime <= now) continue;
|
||||
|
||||
// ID estable: hash simple basado en codigo+dia+horario
|
||||
const stableId = this.stableHash(`${item.codigo}-${item.dia}-${item.horario}`);
|
||||
|
||||
idsToCancel.push(stableId);
|
||||
notificationsToSchedule.push({
|
||||
id: stableId,
|
||||
title: `🎓 Clase en 30 minutos`,
|
||||
body: `${item.asignatura} — Aula ${item.aula} a las ${timeMatch[0]}`,
|
||||
schedule: { at: reminderTime },
|
||||
extra: { codigo: item.codigo }
|
||||
});
|
||||
}
|
||||
|
||||
if (idsToCancel.length > 0) {
|
||||
await LocalNotifications.cancel({ notifications: idsToCancel.map(id => ({ id })) }).catch(() => {});
|
||||
}
|
||||
|
||||
if (notificationsToSchedule.length > 0) {
|
||||
await LocalNotifications.schedule({ notifications: notificationsToSchedule });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error programando recordatorios de clase:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Genera un entero de 32 bits estable a partir de un string (djb2) */
|
||||
private stableHash(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
|
||||
hash = hash >>> 0; // Mantener como unsigned 32-bit
|
||||
}
|
||||
// LocalNotifications requiere IDs positivos y razonables
|
||||
return (hash % 2_000_000) + 100_000;
|
||||
}
|
||||
|
||||
private isItemInFavorites(item: ClaseHorario, favoritos: string[]): boolean {
|
||||
const itemKey = `${item.codigo}::${item.asignatura.trim()}`;
|
||||
if (favoritos.includes(itemKey)) return true;
|
||||
if (favoritos.includes(item.codigo)) {
|
||||
const hasSpecific = favoritos.some(f => f.startsWith(`${item.codigo}::`));
|
||||
return !hasSpecific;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkRoomChanges(oldData: ClaseHorario[], newData: ClaseHorario[], favoritos: string[]) {
|
||||
for (const item of newData) {
|
||||
if (this.isItemInFavorites(item, favoritos)) {
|
||||
const oldItem = oldData.find(o =>
|
||||
o.codigo === item.codigo &&
|
||||
o.asignatura === item.asignatura &&
|
||||
o.dia === item.dia &&
|
||||
o.horario === item.horario
|
||||
);
|
||||
|
||||
if (oldItem) {
|
||||
const roomChanged = oldItem.aula !== item.aula;
|
||||
const linkChanged = oldItem.link !== item.link;
|
||||
|
||||
if (roomChanged || linkChanged) {
|
||||
await this.notifyChange(item, roomChanged, linkChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyChange(item: ClaseHorario, roomChanged: boolean, linkChanged: boolean) {
|
||||
try {
|
||||
let body = '';
|
||||
if (roomChanged && linkChanged) {
|
||||
body = `El aula cambió a: ${item.aula} y el enlace fue actualizado.`;
|
||||
} else if (roomChanged) {
|
||||
body = `El aula ha cambiado al aula: ${item.aula}`;
|
||||
} else if (linkChanged) {
|
||||
body = item.link ? 'Hay un nuevo enlace disponible para tu clase.' : 'El enlace de la clase virtual ha sido removido.';
|
||||
}
|
||||
|
||||
await LocalNotifications.schedule({
|
||||
notifications: [
|
||||
{
|
||||
title: `Cambio en tu clase: ${item.asignatura}`,
|
||||
body: body,
|
||||
id: Math.floor(Math.random() * 10000),
|
||||
schedule: { at: new Date(Date.now() + 500) },
|
||||
extra: { codigo: item.codigo }
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error al enviar notificación:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private parseCsv(csv: string, dia: string): ClaseHorario[] {
|
||||
const result: ClaseHorario[] = [];
|
||||
const rows: string[][] = [];
|
||||
let currentRow: string[] = [];
|
||||
let currentCell = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < csv.length; i++) {
|
||||
const char = csv[i];
|
||||
const nextChar = csv[i + 1];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"' && nextChar === '"') {
|
||||
currentCell += '"';
|
||||
i++;
|
||||
} else if (char === '"') {
|
||||
inQuotes = false;
|
||||
} else {
|
||||
currentCell += char;
|
||||
}
|
||||
} else {
|
||||
if (char === '"') {
|
||||
inQuotes = true;
|
||||
} else if (char === ',') {
|
||||
currentRow.push(currentCell);
|
||||
currentCell = '';
|
||||
} else if (char === '\n' || (char === '\r' && nextChar === '\n')) {
|
||||
currentRow.push(currentCell);
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
currentCell = '';
|
||||
if (char === '\r') i++;
|
||||
} else if (char !== '\r') {
|
||||
currentCell += char;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentCell !== '' || currentRow.length > 0) {
|
||||
currentRow.push(currentCell);
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
let headerRowIndex = -1;
|
||||
let colOffsetX = 0;
|
||||
let linkColIndex = -1; // Índice absoluto de la columna Link (si existe)
|
||||
let horarioColIndex = -1; // Índice absoluto de la columna Horario
|
||||
|
||||
for (let r = 0; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
for (let c = 0; c < row.length; c++) {
|
||||
const cellLower = row[c].trim().toLowerCase();
|
||||
if (cellLower.includes('codigo') || cellLower.includes('código')) {
|
||||
headerRowIndex = r;
|
||||
colOffsetX = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerRowIndex !== -1) break;
|
||||
}
|
||||
|
||||
if (headerRowIndex === -1) return [];
|
||||
|
||||
// Detectar columnas dinámicamente leyendo la fila de encabezados
|
||||
const headerRow = rows[headerRowIndex];
|
||||
for (let c = colOffsetX; c < headerRow.length; c++) {
|
||||
const cellLower = headerRow[c].trim().toLowerCase();
|
||||
if (cellLower === 'link' || cellLower.startsWith('http')) {
|
||||
linkColIndex = c;
|
||||
}
|
||||
if (cellLower.includes('horario')) {
|
||||
horarioColIndex = c;
|
||||
}
|
||||
}
|
||||
// Si no encontramos la columna horario explícitamente, usamos el offset clásico
|
||||
if (horarioColIndex === -1) {
|
||||
horarioColIndex = linkColIndex !== -1 ? colOffsetX + 5 : colOffsetX + 4;
|
||||
}
|
||||
|
||||
for (let r = headerRowIndex + 1; r < rows.length; r++) {
|
||||
const row = rows[r];
|
||||
if (row.length >= colOffsetX + 4) {
|
||||
const codigo = (row[colOffsetX] || '').trim().replace(/\n/g, ' - ');
|
||||
const asignatura = (row[colOffsetX + 1] || '').trim().replace(/\n/g, ' ');
|
||||
const aula = (row[colOffsetX + 2] || '').trim();
|
||||
const carrera = (row[colOffsetX + 3] || '').trim();
|
||||
const horario = (row[horarioColIndex] || '').trim().replace(/([0-9])\s*(hs|horas|h)\b\.?/gi, '$1').trim();
|
||||
const link = linkColIndex !== -1 ? (row[linkColIndex] || '').trim() : undefined;
|
||||
|
||||
if (codigo && asignatura && carrera &&
|
||||
!codigo.toLowerCase().includes('codigo') &&
|
||||
!codigo.toLowerCase().includes('código') &&
|
||||
!codigo.toLowerCase().startsWith('turno')) {
|
||||
result.push({ codigo, asignatura, aula, carrera, horario, dia, link: link || undefined });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { LocalNotifications, LocalNotificationSchema } from '@capacitor/local-notifications';
|
||||
import { Haptics, ImpactStyle } from '@capacitor/haptics';
|
||||
import { EventoAcademico } from './data';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class EventosService {
|
||||
private readonly PREF_EVENTOS = 'eventos_academicos';
|
||||
private eventos: EventoAcademico[] = [];
|
||||
|
||||
constructor() {
|
||||
this.loadEventos();
|
||||
}
|
||||
|
||||
async loadEventos(): Promise<EventoAcademico[]> {
|
||||
const { value } = await Preferences.get({ key: this.PREF_EVENTOS });
|
||||
if (value) {
|
||||
this.eventos = JSON.parse(value);
|
||||
}
|
||||
return this.eventos;
|
||||
}
|
||||
|
||||
getEventosByMateria(codigo: string): EventoAcademico[] {
|
||||
return this.eventos.filter(e => e.materiaCodigo === codigo);
|
||||
}
|
||||
|
||||
getAllEventos(): EventoAcademico[] {
|
||||
return this.eventos;
|
||||
}
|
||||
|
||||
async saveEvento(evento: EventoAcademico) {
|
||||
const index = this.eventos.findIndex(e => e.id === evento.id);
|
||||
if (index > -1) {
|
||||
this.eventos[index] = evento;
|
||||
} else {
|
||||
this.eventos.push(evento);
|
||||
}
|
||||
await this.syncStorage();
|
||||
await this.scheduleNotifications(evento);
|
||||
await Haptics.impact({ style: ImpactStyle.Medium }).catch(() => {});
|
||||
}
|
||||
|
||||
async deleteEvento(id: string) {
|
||||
this.eventos = this.eventos.filter(e => e.id !== id);
|
||||
await this.syncStorage();
|
||||
await this.cancelNotifications(id);
|
||||
await Haptics.impact({ style: ImpactStyle.Light }).catch(() => {});
|
||||
}
|
||||
|
||||
private async syncStorage() {
|
||||
await Preferences.set({
|
||||
key: this.PREF_EVENTOS,
|
||||
value: JSON.stringify(this.eventos)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Programa notificaciones escalonadas para un hito académico: 7 días, 1 día y 2 horas antes.
|
||||
*/
|
||||
async scheduleNotifications(evento: EventoAcademico) {
|
||||
const now = new Date();
|
||||
const eventDate = new Date(evento.fecha);
|
||||
|
||||
if (eventDate <= now) return;
|
||||
|
||||
// Cancelamos notificaciones previas de este ID
|
||||
await this.cancelNotifications(evento.id);
|
||||
|
||||
const notifs: LocalNotificationSchema[] = [];
|
||||
const baseId = this.stringToNumericId(evento.id);
|
||||
|
||||
const milestones = [
|
||||
{ label: 'en 7 días', offset: 7 * 24 * 60 * 60 * 1000, suffix: '7d' },
|
||||
{ label: 'mañana', offset: 24 * 60 * 60 * 1000, suffix: '1d' },
|
||||
{ label: 'en 2 horas', offset: 2 * 60 * 60 * 1000, suffix: '2h' }
|
||||
];
|
||||
|
||||
milestones.forEach((m, i) => {
|
||||
const scheduleAt = new Date(eventDate.getTime() - m.offset);
|
||||
if (scheduleAt > now) {
|
||||
notifs.push({
|
||||
id: baseId + i,
|
||||
title: `📌 Recordatorio: ${evento.tipo}`,
|
||||
body: `${evento.materiaCodigo}: ${m.label} es el examen/entrega.`,
|
||||
schedule: { at: scheduleAt },
|
||||
extra: { eventoId: evento.id, codigo: evento.materiaCodigo }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (notifs.length > 0) {
|
||||
await LocalNotifications.schedule({ notifications: notifs });
|
||||
}
|
||||
}
|
||||
|
||||
async cancelNotifications(eventoId: string) {
|
||||
const baseId = this.stringToNumericId(eventoId);
|
||||
const ids = [baseId, baseId + 1, baseId + 2];
|
||||
await LocalNotifications.cancel({ notifications: ids.map(id => ({ id })) }).catch(() => {});
|
||||
}
|
||||
|
||||
private stringToNumericId(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash % 100000) + 200000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
||||
import { FilePicker } from '@capawesome/capacitor-file-picker';
|
||||
import { Share } from '@capacitor/share';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { EventoAcademico } from './data';
|
||||
|
||||
export interface AppData {
|
||||
favoritos: string[];
|
||||
eventos: EventoAcademico[];
|
||||
aprobadas: string[];
|
||||
optativas: {slot: string, materia: string}[];
|
||||
encurso: string[];
|
||||
siuMaterias?: {nombre: string, nota: string, origen: string}[];
|
||||
siuLastSync?: string | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LocalStorageService {
|
||||
|
||||
private readonly PREF_KEY = 'aulas_unicaba_data';
|
||||
private readonly BACKUP_PREFIX = 'Aulas_UNICABA_Respaldo_';
|
||||
|
||||
constructor() {}
|
||||
|
||||
// --- INTERNAL PREFERENCES (ROCK SOLID OFFLINE) ---
|
||||
|
||||
async saveData(data: AppData): Promise<void> {
|
||||
try {
|
||||
await Preferences.set({
|
||||
key: this.PREF_KEY,
|
||||
value: JSON.stringify(data)
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error saving to Preferences', e);
|
||||
}
|
||||
}
|
||||
|
||||
async loadData(): Promise<AppData | null> {
|
||||
try {
|
||||
let { value } = await Preferences.get({ key: this.PREF_KEY });
|
||||
|
||||
if (!value) {
|
||||
// Intento de migración la primera vez si el usuario viene de versiones viejas con CSV
|
||||
await this.migrateLegacyCsvIfNeeded();
|
||||
const res = await Preferences.get({ key: this.PREF_KEY });
|
||||
value = res.value;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
const parsed = JSON.parse(value);
|
||||
return {
|
||||
favoritos: parsed.favoritos || [],
|
||||
eventos: parsed.eventos || [],
|
||||
aprobadas: parsed.aprobadas || [],
|
||||
optativas: parsed.optativas || [],
|
||||
encurso: parsed.encurso || [],
|
||||
siuMaterias: parsed.siuMaterias || [],
|
||||
siuLastSync: parsed.siuLastSync || null
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
console.error('Error loading from Preferences', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async exportBackup(data: AppData, localDownload: boolean = false): Promise<boolean> {
|
||||
try {
|
||||
const backupData = JSON.stringify(data, null, 2);
|
||||
const safeDate = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
|
||||
const fileName = `${this.BACKUP_PREFIX}${safeDate}.json`;
|
||||
|
||||
const platform = Capacitor.getPlatform();
|
||||
|
||||
if (localDownload && (platform === 'android' || platform === 'web')) {
|
||||
// En Android y Web, forzamos la descarga extrayéndolo físicamente a Descargas
|
||||
const blob = new Blob([backupData], { type: 'application/json' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
// iOS: El Share sheet sí incluye la opción "Guardar en Archivos" por defecto
|
||||
const writeResult = await Filesystem.writeFile({
|
||||
path: fileName,
|
||||
data: backupData,
|
||||
directory: Directory.Cache,
|
||||
encoding: Encoding.UTF8
|
||||
});
|
||||
|
||||
await Share.share({
|
||||
title: 'Respaldo Aulas UNICABA',
|
||||
text: 'Este archivo contiene el respaldo de tus materias.',
|
||||
url: writeResult.uri,
|
||||
dialogTitle: 'Guardar Respaldo en Archivos'
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Error exporting backup', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async importBackup(): Promise<AppData | null> {
|
||||
try {
|
||||
// 1. Let the user explicitly pick the file from their OS sin restriccion
|
||||
const result = await FilePicker.pickFiles({
|
||||
readData: true
|
||||
});
|
||||
|
||||
const file = result.files[0];
|
||||
if (!file || !file.data) return null;
|
||||
|
||||
// 2. Decode the Base64 data from the picker
|
||||
const text = decodeURIComponent(escape(window.atob(file.data)));
|
||||
|
||||
// Intentamos cargar JSON puro primero
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const importedData: AppData = {
|
||||
favoritos: parsed.favoritos || [],
|
||||
eventos: parsed.eventos || [],
|
||||
aprobadas: parsed.aprobadas || [],
|
||||
optativas: parsed.optativas || [],
|
||||
encurso: parsed.encurso || [],
|
||||
siuMaterias: parsed.siuMaterias || [],
|
||||
siuLastSync: parsed.siuLastSync || null
|
||||
};
|
||||
await this.saveData(importedData);
|
||||
return importedData;
|
||||
}
|
||||
} catch (jsonErr) {
|
||||
// Fallback: intentar parsear el legado .csv
|
||||
const legacyParsed = await this.parseLegacyCsv(text, true);
|
||||
return legacyParsed;
|
||||
}
|
||||
|
||||
// Retornar en caso de que sea un json malformado que no falle el JSON.parse
|
||||
return null;
|
||||
} catch(e) {
|
||||
console.warn('File picker cancelado o error al importar', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- MIGRATION LOGIC ---
|
||||
|
||||
private async migrateLegacyCsvIfNeeded(): Promise<void> {
|
||||
try {
|
||||
const contents = await Filesystem.readFile({
|
||||
path: 'aulas_unicaba_datos.csv',
|
||||
directory: Directory.Documents,
|
||||
encoding: Encoding.UTF8
|
||||
});
|
||||
if (contents && contents.data) {
|
||||
const legacyData = await this.parseLegacyCsv(contents.data.toString(), true);
|
||||
if (legacyData) {
|
||||
console.log('Migración Exitosa. Datos viejos capturados en el nuevo Preference API.');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore, user is fresh
|
||||
}
|
||||
}
|
||||
|
||||
private async parseLegacyCsv(text: string, saveAfter: boolean): Promise<AppData | null> {
|
||||
try {
|
||||
const lines = text.split('\n');
|
||||
const favs: string[] = [];
|
||||
const evs: EventoAcademico[] = [];
|
||||
const aprobadas: string[] = [];
|
||||
const encurso: string[] = [];
|
||||
const optativasMap: {slot: string, materia: string}[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
const parts = line.split(',');
|
||||
if (parts.length < 2) continue;
|
||||
const tipo = parts[0];
|
||||
|
||||
if (tipo === 'favorito') {
|
||||
favs.push(parts[1]);
|
||||
} else if (tipo === 'aprobada') {
|
||||
aprobadas.push(parts[1]);
|
||||
} else if (tipo === 'en_curso') {
|
||||
encurso.push(parts[1]);
|
||||
} else if (tipo === 'optativa' && parts[2]) {
|
||||
optativasMap.push({ slot: parts[1], materia: parts[2] });
|
||||
} else if (tipo === 'evento' && parts.length >= 5) {
|
||||
evs.push({
|
||||
materiaCodigo: parts[1],
|
||||
id: parts[2],
|
||||
tipo: parts[3] as any,
|
||||
fecha: parts[4],
|
||||
aula: parts[5] || '',
|
||||
link: parts[6] || '',
|
||||
notas: (parts[7] || '').replace(/;/g, ',')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const importedData: AppData = {
|
||||
favoritos: favs, eventos: evs, aprobadas: aprobadas, optativas: optativasMap, encurso: encurso, siuMaterias: [], siuLastSync: null
|
||||
};
|
||||
|
||||
if (saveAfter) {
|
||||
await this.saveData(importedData);
|
||||
}
|
||||
return importedData;
|
||||
} catch (e) {
|
||||
console.error('Error parsing legacy CSV', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CapacitorHttp, HttpResponse } from '@capacitor/core';
|
||||
import { Filesystem, Directory } from '@capacitor/filesystem';
|
||||
import { FileOpener } from '@capacitor-community/file-opener';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SiuService {
|
||||
constructor(private authService: AuthService) {}
|
||||
private readonly BASE_URL = 'https://autogestion.udelaciudad.edu.ar/caba';
|
||||
|
||||
async getAcademicHistory(dni: string, pass: string, carreraId: string = '30'): Promise<any[]> {
|
||||
try {
|
||||
await this.authenticateSiu(dni, pass);
|
||||
await this.selectCareer(carreraId);
|
||||
|
||||
// 3. OBTENER PLAN DE ESTUDIO
|
||||
const resPlan: HttpResponse = await CapacitorHttp.get({
|
||||
url: `${this.BASE_URL}/plan_estudio`,
|
||||
});
|
||||
|
||||
// 4. PARSEO DEL JSON EMBEBIDO
|
||||
const rawHtml = resPlan.data;
|
||||
const regex = /on_arrival\((\{.*?"id":"info_plan".*?\})\);/s;
|
||||
const match = rawHtml.match(regex);
|
||||
|
||||
if (match && match[1]) {
|
||||
const jsonData = JSON.parse(match[1]);
|
||||
const contentHtml = jsonData.content;
|
||||
return this.parseHtmlSubjects(contentHtml);
|
||||
}
|
||||
|
||||
throw new Error('No se encontró el bloque de materias');
|
||||
} catch (error) {
|
||||
console.error('Error SIU Academic History:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getInscripcionesActivas(dni: string, pass: string, carreraId: string = '30'): Promise<any[]> {
|
||||
try {
|
||||
await this.authenticateSiu(dni, pass);
|
||||
await this.selectCareer(carreraId);
|
||||
|
||||
// OBTENER PÁGINA DE CURSADA
|
||||
const resCursada: HttpResponse = await CapacitorHttp.get({
|
||||
url: `${this.BASE_URL}/cursada`,
|
||||
});
|
||||
|
||||
const rawHtml = resCursada.data;
|
||||
|
||||
// 1. Extraer directamente de "notificaciones" donde SIU provee el array JSON "asignaciones"
|
||||
const regexNotif = /on_arrival\((\{.*?"id":"notificaciones".*?\})\);/s;
|
||||
const matchNotif = rawHtml.match(regexNotif);
|
||||
if (matchNotif && matchNotif[1]) {
|
||||
try {
|
||||
const jsonNotif = JSON.parse(matchNotif[1]);
|
||||
const asignaciones = jsonNotif.info?.asignaciones;
|
||||
if (Array.isArray(asignaciones) && asignaciones.length > 0) {
|
||||
return asignaciones.map((asig: any) => ({
|
||||
nombre: asig.NOMBRE_MATERIA || asig.MATERIA,
|
||||
codigo: asig.NOMBRE || asig.MATERIA, // ej: "ASIG00127-E1-TD"
|
||||
comision: asig.COMISION,
|
||||
dia: asig.DIA_SEMANA
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('No se pudieron leer asignaciones estructuradas:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: extraer del bloque HTML "info_materia" (tabla .js-mis-inscripciones)
|
||||
const regex = /on_arrival\((\{.*?"id":"info_materia".*?\})\);/s;
|
||||
const match = rawHtml.match(regex);
|
||||
|
||||
if (match && match[1]) {
|
||||
const jsonData = JSON.parse(match[1]);
|
||||
const contentHtml = jsonData.content;
|
||||
return this.parseInscripcionesActivasHtml(contentHtml);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Error SIU Active Inscriptions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticateSiu(dni: string, pass: string): Promise<void> {
|
||||
console.log('Autenticando en SIU...');
|
||||
const loginData = `usuario=${dni}&password=${pass}&login=Ingresar`;
|
||||
await CapacitorHttp.post({
|
||||
url: `${this.BASE_URL}/acceso?auth=form`,
|
||||
data: loginData,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
}
|
||||
|
||||
private async selectCareer(carreraId: string): Promise<void> {
|
||||
console.log('Seleccionando carrera:', carreraId);
|
||||
await CapacitorHttp.get({
|
||||
url: `${this.BASE_URL}/acceso/cambiar_carrera?id=${carreraId}&op=inicio_alumno`,
|
||||
});
|
||||
// Hit inicio_alumno to ensure context is established
|
||||
await CapacitorHttp.get({
|
||||
url: `${this.BASE_URL}/inicio_alumno`,
|
||||
});
|
||||
}
|
||||
|
||||
private extractTobaContent(html: string, pageletId: string = 'pagelet'): string {
|
||||
const regexes = [
|
||||
/kernel\.renderer\.on_arrival\((\{.*?\})\);/gs,
|
||||
/on_arrival\((\{.*?\})\);/gs
|
||||
];
|
||||
|
||||
for (const regex of regexes) {
|
||||
let match;
|
||||
// Reset regex index for global searches
|
||||
regex.lastIndex = 0;
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
try {
|
||||
const jsonData = JSON.parse(match[1]);
|
||||
if (jsonData.id === pageletId || jsonData.info?.id === pageletId) {
|
||||
return jsonData.content || '';
|
||||
}
|
||||
} catch (e) { continue; }
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async downloadCertificate(dni: string, pass: string): Promise<{ base64: string, filename: string }> {
|
||||
console.log('Iniciando descarga de certificado para:', dni);
|
||||
try {
|
||||
await this.authenticateSiu(dni, pass);
|
||||
await this.selectCareer('30');
|
||||
console.log('Login y selección de carrera (ID 30) listos');
|
||||
|
||||
// 1. VALIDAR SI EXISTE UNO VIGENTE (< 30 DÍAS)
|
||||
const resSolicitudes: HttpResponse = await CapacitorHttp.get({
|
||||
url: `${this.BASE_URL}/solicitudes`,
|
||||
});
|
||||
console.log('Página de solicitudes obtenida');
|
||||
|
||||
const contentHtmlSolicitudes = this.extractTobaContent(resSolicitudes.data, 'pagelet');
|
||||
if (contentHtmlSolicitudes) {
|
||||
const existingCert = this.findValidCertificate(contentHtmlSolicitudes);
|
||||
if (existingCert) {
|
||||
console.log('Certificado vigente encontrado:', existingCert.filename);
|
||||
return await this.downloadFile(existingCert.url, existingCert.filename);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. GENERAR NUEVA SOLICITUD (HEADLESS)
|
||||
console.log('No hay certificado vigente o no se pudo leer la tabla. Intentando generar uno nuevo...');
|
||||
const resGestion: HttpResponse = await CapacitorHttp.get({
|
||||
url: `${this.BASE_URL}/solicitudes/solicitud_gestion`,
|
||||
});
|
||||
|
||||
const contentHtmlGestion = this.extractTobaContent(resGestion.data, 'pagelet');
|
||||
if (!contentHtmlGestion) {
|
||||
console.error('HTML completo de gestión no contiene on_arrival de pagelet. Muestra:', resGestion.data.substring(0, 1000));
|
||||
throw new Error('No se pudo localizar el generador (content missing)');
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(contentHtmlGestion, 'text/html');
|
||||
const iframe = doc.querySelector('iframe#frameSolicitud') as HTMLIFrameElement;
|
||||
let iframeSrc = iframe?.getAttribute('src');
|
||||
|
||||
if (!iframeSrc) {
|
||||
throw new Error('No se pudo localizar el generador (iframe src missing)');
|
||||
}
|
||||
|
||||
console.log('Iframe localizado, src:', iframeSrc);
|
||||
|
||||
// Asegurar que la URL del iframe es completa
|
||||
if (!iframeSrc.startsWith('http')) {
|
||||
const baseUrlParsed = new URL(this.BASE_URL);
|
||||
iframeSrc = `${baseUrlParsed.origin}${iframeSrc.startsWith('/') ? '' : '/'}${iframeSrc}`;
|
||||
}
|
||||
|
||||
// Trigger the print event via URL parameters
|
||||
const printUrl = iframeSrc.includes('?')
|
||||
? `${iframeSrc}&toba_ei_notificar=imprimir`
|
||||
: `${iframeSrc}?toba_ei_notificar=imprimir`;
|
||||
|
||||
console.log('Solicitando generación de PDF:', printUrl);
|
||||
return await this.downloadFile(printUrl, `Certificado_Alumno_Regular_${new Date().getTime()}.pdf`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error detallado en SIU Certificate:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private findValidCertificate(html: string): { url: string, filename: string } | null {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const rows = Array.from(doc.querySelectorAll('tr'));
|
||||
|
||||
for (const row of rows) {
|
||||
const cells = Array.from(row.querySelectorAll('td'));
|
||||
if (cells.length >= 3) {
|
||||
const tipo = cells[0].textContent?.trim().toLowerCase() || '';
|
||||
const presentarA = cells[2].textContent?.trim().toLowerCase() || '';
|
||||
|
||||
// Identificar constancia de alumno regular
|
||||
if (tipo.includes('alumno regular')) {
|
||||
const fechaStr = cells[1].textContent?.trim() || '';
|
||||
console.log(`Revisando: ${tipo} | Para: ${presentarA} | Fecha: ${fechaStr}`);
|
||||
|
||||
if (this.isWithin30Days(fechaStr)) {
|
||||
// El usuario indico que si dice "Quien corresponda" o similares (como "Google LLC") sirve.
|
||||
// Solo descartamos si es para un destinatario muy especifico que no sea el solicitado.
|
||||
const downloadLink = row.querySelector('a.descargar_cert, a[href*="descargar_comprobante"]') as HTMLAnchorElement;
|
||||
if (downloadLink) {
|
||||
return {
|
||||
url: downloadLink.getAttribute('href') || '',
|
||||
filename: `Certificado_SIU_${presentarA.replace(/\s+/g, '_')}_${fechaStr.replace(/\//g, '-')}.pdf`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private isWithin30Days(fechaStr: string): boolean {
|
||||
// Formato esperado: DD/MM/YYYY
|
||||
const parts = fechaStr.split('/');
|
||||
if (parts.length !== 3) return false;
|
||||
const date = new Date(parseInt(parts[2]), parseInt(parts[1]) - 1, parseInt(parts[0]));
|
||||
const diff = new Date().getTime() - date.getTime();
|
||||
const days = diff / (1000 * 60 * 60 * 24);
|
||||
return days < 30;
|
||||
}
|
||||
|
||||
private async downloadFile(url: string, filename: string): Promise<{ base64: string, filename: string }> {
|
||||
// Si la URL es relativa, completarla
|
||||
const fullUrl = url.startsWith('http') ? url : `${this.BASE_URL}/${url.startsWith('/') ? url.substring(1) : url}`;
|
||||
|
||||
const response: HttpResponse = await CapacitorHttp.get({
|
||||
url: fullUrl,
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
return {
|
||||
base64: response.data, // CapacitorHttp con responseType blob devuelve base64 string
|
||||
filename
|
||||
};
|
||||
}
|
||||
|
||||
async saveAndOpenFile(base64Data: string, filename: string): Promise<void> {
|
||||
try {
|
||||
const savedFile = await Filesystem.writeFile({
|
||||
path: `Certificados/${filename}`,
|
||||
data: base64Data,
|
||||
directory: Directory.Documents,
|
||||
recursive: true
|
||||
});
|
||||
|
||||
await FileOpener.open({
|
||||
filePath: savedFile.uri,
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving or opening file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private parseInscripcionesActivasHtml(html: string): any[] {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
// Buscamos los elementos que contienen las inscripciones activas
|
||||
const rows = Array.from(doc.querySelectorAll('.js-mis-inscripciones td, .inscripciones-lista td, .cuadro_materias td, table.tabla_cursada td'));
|
||||
|
||||
const results: any[] = [];
|
||||
for (const td of rows) {
|
||||
const text = td.textContent?.trim() || '';
|
||||
if (!text || text.length < 5) continue;
|
||||
|
||||
// Caso 1: "Nombre Materia - ASIG00127-E1-TD"
|
||||
if (text.includes(' - ')) {
|
||||
const parts = text.split(' - ');
|
||||
const codigo = parts[parts.length - 1].trim();
|
||||
const nombre = parts.slice(0, -1).join(' - ').trim();
|
||||
if (codigo.toUpperCase().includes('ASIG') || codigo.length >= 4) {
|
||||
results.push({ nombre, codigo });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Caso 2: "Nombre Materia (ASIG00127-E1-TD)"
|
||||
const matchParen = text.match(/^(.*?)\s*\((ASIG[^\)]+)\)/i);
|
||||
if (matchParen) {
|
||||
results.push({ nombre: matchParen[1].trim(), codigo: matchParen[2].trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Caso 3: contiene código ASIG embebido
|
||||
const matchAsig = text.match(/(ASIG[A-Z0-9\-_]+)/i);
|
||||
if (matchAsig) {
|
||||
const codigo = matchAsig[1].trim();
|
||||
const nombre = text.replace(codigo, '').replace(/[-–()]/g, '').trim();
|
||||
results.push({ nombre, codigo });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private parseHtmlSubjects(html: string): any[] {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const rows = Array.from(doc.querySelectorAll('tr'));
|
||||
|
||||
return rows.map(row => {
|
||||
const cols = row.querySelectorAll('td');
|
||||
if (cols.length >= 6) {
|
||||
const nombreRaw = cols[0].textContent?.trim() || '';
|
||||
return {
|
||||
nombre: nombreRaw.split(' (')[0],
|
||||
nota: cols[4].textContent?.trim(),
|
||||
origen: cols[5].textContent?.trim()
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(item => item && item.nombre);
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="350" height="140" xmlns="http://www.w3.org/2000/svg" style="background:#f6f7f9"><g fill="none" fill-rule="evenodd"><path fill="#F04141" style="mix-blend-mode:multiply" d="M61.905-34.23l96.194 54.51-66.982 54.512L22 34.887z"/><circle fill="#10DC60" style="mix-blend-mode:multiply" cx="155.5" cy="135.5" r="57.5"/><path fill="#3880FF" style="mix-blend-mode:multiply" d="M208.538 9.513l84.417 15.392L223.93 93.93z"/><path fill="#FFCE00" style="mix-blend-mode:multiply" d="M268.625 106.557l46.332-26.75 46.332 26.75v53.5l-46.332 26.75-46.332-26.75z"/><circle fill="#7044FF" style="mix-blend-mode:multiply" cx="299.5" cy="9.5" r="38.5"/><rect fill="#11D3EA" style="mix-blend-mode:multiply" transform="rotate(-60 148.47 37.886)" x="143.372" y="-7.056" width="10.196" height="89.884" rx="5.098"/><path d="M-25.389 74.253l84.86 8.107c5.498.525 9.53 5.407 9.004 10.905a10 10 0 0 1-.057.477l-12.36 85.671a10.002 10.002 0 0 1-11.634 8.42l-86.351-15.226c-5.44-.959-9.07-6.145-8.112-11.584l13.851-78.551a10 10 0 0 1 10.799-8.219z" fill="#7044FF" style="mix-blend-mode:multiply"/><circle fill="#0CD1E8" style="mix-blend-mode:multiply" cx="273.5" cy="106.5" r="20.5"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
export const environment = {
|
||||
production: true
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file can be replaced during build by using the `fileReplacements` array.
|
||||
// `ng build` replaces `environment.ts` with `environment.prod.ts`.
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
export const environment = {
|
||||
production: false
|
||||
};
|
||||
|
||||
/*
|
||||
* For easier debugging in development mode, you can import the following file
|
||||
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
|
||||
*
|
||||
* This import should be commented out in production mode because it will have a negative impact
|
||||
* on performance if an error is thrown.
|
||||
*/
|
||||
// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* App Global CSS
|
||||
* ----------------------------------------------------------------------------
|
||||
* Put style rules here that you want to apply globally. These styles are for
|
||||
* the entire app and not just one component. Additionally, this file can be
|
||||
* used as an entry point to import other CSS/Sass files to be included in the
|
||||
* output CSS.
|
||||
* For more information on global stylesheets, visit the documentation:
|
||||
* https://ionicframework.com/docs/layout/global-stylesheets
|
||||
*/
|
||||
|
||||
/* Core CSS required for Ionic components to work properly */
|
||||
@import "@ionic/angular/css/core.css";
|
||||
|
||||
/* Basic CSS for apps built with Ionic */
|
||||
@import "@ionic/angular/css/normalize.css";
|
||||
@import "@ionic/angular/css/structure.css";
|
||||
@import "@ionic/angular/css/typography.css";
|
||||
@import "@ionic/angular/css/display.css";
|
||||
|
||||
/* Optional CSS utils that can be commented out */
|
||||
@import "@ionic/angular/css/padding.css";
|
||||
@import "@ionic/angular/css/float-elements.css";
|
||||
@import "@ionic/angular/css/text-alignment.css";
|
||||
@import "@ionic/angular/css/text-transformation.css";
|
||||
@import "@ionic/angular/css/flex-utils.css";
|
||||
|
||||
/**
|
||||
* Ionic Dark Mode
|
||||
* -----------------------------------------------------
|
||||
* For more info, please see:
|
||||
* https://ionicframework.com/docs/theming/dark-mode
|
||||
*/
|
||||
|
||||
/* @import "@ionic/angular/css/palettes/dark.always.css"; */
|
||||
/* @import "@ionic/angular/css/palettes/dark.class.css"; */
|
||||
@import "@ionic/angular/css/palettes/dark.system.css";
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Cartelera Aulas UNICABA</title>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
|
||||
<link rel="icon" type="image/png" href="assets/icon/favicon.png" />
|
||||
|
||||
<!-- add to homescreen for ios -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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';
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.log(err));
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icons/icon-48.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "48x48",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-72.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "72x72",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-96.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "96x96",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-128.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "128x128",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-192.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-256.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-512.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* This file includes polyfills needed by Angular and is loaded before the app.
|
||||
* You can add your own extra polyfills to this file.
|
||||
*
|
||||
* This file is divided into 2 sections:
|
||||
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
|
||||
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
|
||||
* file.
|
||||
*
|
||||
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
|
||||
* automatically update themselves. This includes recent versions of Safari, Chrome (including
|
||||
* Opera), Edge on the desktop, and iOS and Chrome on mobile.
|
||||
*
|
||||
* Learn more in https://angular.io/guide/browser-support
|
||||
*/
|
||||
|
||||
/***************************************************************************************************
|
||||
* BROWSER POLYFILLS
|
||||
*/
|
||||
|
||||
/**
|
||||
* By default, zone.js will patch all possible macroTask and DomEvents
|
||||
* user can disable parts of macroTask/DomEvents patch by setting following flags
|
||||
* because those flags need to be set before `zone.js` being loaded, and webpack
|
||||
* will put import in the top of bundle, so user need to create a separate file
|
||||
* in this directory (for example: zone-flags.ts), and put the following flags
|
||||
* into that file, and then add the following code before importing zone.js.
|
||||
* import './zone-flags';
|
||||
*
|
||||
* The flags allowed in zone-flags.ts are listed here.
|
||||
*
|
||||
* The following flags will work for all browsers.
|
||||
*
|
||||
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
|
||||
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
|
||||
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
|
||||
*
|
||||
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
|
||||
* with the following flag, it will bypass `zone.js` patch for IE/Edge
|
||||
*
|
||||
* (window as any).__Zone_enable_cross_context_check = true;
|
||||
*
|
||||
*/
|
||||
|
||||
import './zone-flags';
|
||||
|
||||
/***************************************************************************************************
|
||||
* Zone JS is required by default for Angular itself.
|
||||
*/
|
||||
import 'zone.js'; // Included with Angular CLI.
|
||||
|
||||
|
||||
/***************************************************************************************************
|
||||
* APPLICATION IMPORTS
|
||||
*/
|
||||
@@ -0,0 +1,14 @@
|
||||
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
|
||||
|
||||
import 'zone.js/testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting
|
||||
} from '@angular/platform-browser-dynamic/testing';
|
||||
|
||||
// First, initialize the Angular testing environment.
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting(),
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
:root {
|
||||
/** primary **/
|
||||
--ion-color-primary: #B43E8E;
|
||||
--ion-color-primary-rgb: 180, 62, 142;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-primary-shade: #9e367d;
|
||||
--ion-color-primary-tint: #bc5199;
|
||||
|
||||
/** secondary **/
|
||||
--ion-color-secondary: #b43e8e;
|
||||
--ion-color-secondary-rgb: 180, 62, 142;
|
||||
--ion-color-secondary-contrast: #ffffff;
|
||||
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-secondary-shade: #9e367d;
|
||||
--ion-color-secondary-tint: #bc5199;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Prevents Angular change detection from
|
||||
* running with certain Web Component callbacks
|
||||
*/
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
(window as any).__Zone_disable_customElements = true;
|
||||