feat: gestion RBAC granular, importacion sheets, filtrado de aulas virtuales y pisos, capacidad infinita y traduccion

This commit is contained in:
Carlos Tello
2026-09-03 03:08:49 -03:00
parent d1d4be0428
commit 5cc33cc7a0
138 changed files with 14419 additions and 1490 deletions
@@ -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();
});
});
File diff suppressed because it is too large Load Diff
@@ -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);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

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.
+37
View File
@@ -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";
+27
View File
@@ -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>
+22
View File
@@ -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
*/
+14
View File
@@ -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;