Primera versión estable v1.1.5 - Aulas UNICABA

This commit is contained in:
2026-03-25 16:50:25 -03:00
commit d3243c0166
143 changed files with 22908 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
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: '',
redirectTo: 'home',
pathMatch: 'full'
},
];
@NgModule({
imports: [
RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
],
exports: [RouterModule]
})
export class AppRoutingModule { }
+3
View File
@@ -0,0 +1,3 @@
<ion-app>
<ion-router-outlet></ion-router-outlet>
</ion-app>
View File
+21
View File
@@ -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();
});
});
+70
View File
@@ -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 } });
});
}
});
}
}
+17
View File
@@ -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,67 @@
<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="ion-padding">
<!-- Loading State -->
<div class="state-container" *ngIf="state === 'loading'">
<ion-spinner name="crescent" color="primary"></ion-spinner>
<p class="loading-text">Conectando con el campus y generando credencial...</p>
</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'">
<img [src]="'data:image/png;base64,' + credentialImage" class="credential-img" alt="Credencial UNICABA" />
<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,79 @@
.state-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 80vh;
padding: 20px;
}
.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;
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;
padding-top: 40px;
.credential-img {
width: 100%;
max-width: 400px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
border: 1px solid #efefef;
margin-bottom: 30px;
}
.credential-actions {
width: 100%;
max-width: 400px;
ion-button {
margin-top: 10px;
}
}
}
@@ -0,0 +1,124 @@
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 { 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 = '';
dni: string = '';
password: string = '';
credentialImage: string = '';
showPassword: boolean = false;
originalBrightness: number | null = null;
constructor(
private modalCtrl: ModalController,
private credencialService: CredencialService,
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';
try {
// 1. Try to load cached image first for instant load
const cachedImg = await this.credencialService.getCachedImage();
if (cachedImg) {
this.credentialImage = cachedImg;
this.state = 'success';
// Even if we show cache, load credentials to fields in background in case they want to update
const stored = await this.credencialService.getStoredCredentials();
this.dni = stored.dni || '';
this.password = stored.pass || '';
return;
}
// 2. If no cached image, check for credentials to auto-login
const stored = await this.credencialService.getStoredCredentials();
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);
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.credencialService.clearAll();
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();
}
}
+16
View File
@@ -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 {}
+21
View File
@@ -0,0 +1,21 @@
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';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
HomePageRoutingModule,
CredencialModalComponent
],
declarations: [HomePage]
})
export class HomePageModule {}
+142
View File
@@ -0,0 +1,142 @@
<ion-header [translucent]="true">
<ion-toolbar color="secondary">
<div class="header-content">
<img src="assets/logo_unicaba_mini.png" class="header-logo" alt="UNICABA Logo" (click)="openCredencial()"
style="cursor: pointer;" title="Ver Credencial Digital">
<ion-title class="ion-text-center">
Cartelera de Aulas
</ion-title>
<ion-buttons slot="end">
<ion-button (click)="isInfoModalOpen = true">
<ion-icon slot="icon-only" name="information-circle-outline"></ion-icon>
</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>
</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-modal [isOpen]="isInfoModalOpen" (didDismiss)="isInfoModalOpen = false">
<ng-template>
<ion-header>
<ion-toolbar color="primary">
<ion-title>Información y Privacidad</ion-title>
<ion-buttons slot="end">
<ion-button (click)="isInfoModalOpen = false">Cerrar</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding privacy-content">
<h2>¿Qué hace esta aplicación?</h2>
<p><strong>Aulas UNICABA</strong> es un visor especializado e independiente para la cartelera de horarios, aulas y
uso de credencial de la Universidad de la Ciudad.</p>
<ul>
<li>Consulta materias, horarios y aulas rápidamente.</li>
<li>Sigue tus <strong>Favoritos</strong> y recibe <strong>alertas locales</strong> si cambian de aula.</li>
<li><strong>¡NUEVO!</strong> Visualiza tu <strong>Credencial Digital</strong> de estudiante tocando el logo de
la UNICABA en la cabecera.</li>
</ul>
<h2>Credencial Digital (Opcional)</h2>
<p>El inicio de sesión para obtener la credencial es <strong>100% opcional</strong> y completamente independiente
de la cartelera de aulas (que puedes seguir usando sin loguearte).</p>
<p>Tus credenciales (DNI y Clave) se comunican <strong>directamente</strong> con el Campus Virtual (Moodle) para
descargar el PDF de tu código QR, sin pasar por ningún servidor intermediario. Luego, la imagen queda guardada
localmente en tu celular para que puedas mostrarla incluso sin conexión a internet.</p>
<h2>Almacenamiento Seguro y Persistente</h2>
<p>Tus datos son tuyos. Aseguramos que tus materias favoritas se guarden automáticamente en un archivo
<code>.csv</code> seguro dentro de la carpeta "Documentos" de tu dispositivo.
</p>
<p>Gracias a esto, toda tu configuración y datos están a salvo y <strong>sobrevivirán a desinstalaciones</strong>
o actualizaciones de la aplicación sin tener que subirlos a la nube.</p>
<h2>Privacidad Absoluta</h2>
<p><strong>Sin Intermediarios:</strong> No recopilamos nombres, contraseñas, ni correos. Todo el procesamiento se
realiza en la memoria de tu teléfono.</p>
<p><strong>Sin Rastreadores:</strong> No incluimos publicidad, telemetría ni analíticas de terceros.</p>
<p class="ion-text-center"><small>Versión 1.1.5 - 25/03/2026</small></p>
</ion-content>
</ng-template>
</ion-modal>
<ion-content [fullscreen]="true" color="light">
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="Desliza para actualizar" refreshingSpinner="crescent"
refreshingText="Actualizando..."></ion-refresher-content>
</ion-refresher>
<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">
<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">
<ion-icon class="share-icon" name="share-social-outline" color="medium"
(click)="shareItem(item)"></ion-icon>
<ion-icon class="fav-icon" [name]="isFavorito(item.codigo) ? 'star' : 'star-outline'"
[color]="isFavorito(item.codigo) ? 'warning' : 'medium'" (click)="toggleFavorito(item)"></ion-icon>
</div>
</div>
</ion-card-header>
<ion-card-content>
<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!)">
<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">
<p>No se encontraron horarios para la selección actual.</p>
</div>
</ion-content>
<ion-footer>
<ion-toolbar color="primary" class="ion-no-padding">
<div class="footer-content" (click)="openLink('https://www.google.com/maps/place/UdelaCiudad/data=!4m2!3m1!1s0x0:0x6dab35ab5ab98245?sa=X&ved=1t:2428&ictx=111')" style="cursor: pointer;" title="Mostrar en el mapa">
<div class="footer-text">
<small><ion-icon name="location-outline" style="vertical-align: bottom;"></ion-icon> Tte. Gral. Juan Domingo Perón 802 (C1038AAR)<br />CABA - Argentina</small>
</div>
</div>
</ion-toolbar>
</ion-footer>
+212
View File
@@ -0,0 +1,212 @@
.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-content {
display: flex;
align-items: center;
padding: 0 10px;
.header-logo {
height: 36px;
width: auto;
background-color: white;
padding: 4px;
border-radius: 5px;
position: absolute;
left: 10px;
z-index: 10;
}
}
// Footer replica styles
.footer-content {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
.footer-text {
color: white;
font-size: 13px;
line-height: 1.2;
text-align: center;
}
}
.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,
.fav-icon {
font-size: 1.5rem;
cursor: pointer;
transition: transform 0.15s;
&:active {
transform: scale(1.2);
}
}
}
.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);
}
}
}
+24
View File
@@ -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();
});
});
+357
View File
@@ -0,0 +1,357 @@
import { Component, OnInit } from '@angular/core';
import { ToastController, ModalController, AlertController } from '@ionic/angular';
import { DataService, ClaseHorario } from '../services/data';
import { Preferences } from '@capacitor/preferences';
import { Filesystem } from '@capacitor/filesystem';
import { LocalNotifications } from '@capacitor/local-notifications';
import { Haptics, ImpactStyle } from '@capacitor/haptics';
import { Share } from '@capacitor/share';
import { ActivatedRoute } from '@angular/router';
import { CredencialModalComponent } from '../components/credencial-modal/credencial-modal.component';
import { CsvStorageService } from '../services/csv-storage.service';
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
standalone: false,
})
export class HomePage implements OnInit {
horarios: ClaseHorario[] = [];
horariosFiltrados: ClaseHorario[] = [];
favoritos: string[] = [];
viewMode: 'favoritos' | 'todas' = 'todas';
searchText: string = '';
highlightedCodigo: string | null = null;
loading: boolean = true;
errorMsg: string = '';
isInfoModalOpen: boolean = false;
constructor(
private dataService: DataService,
private toastCtrl: ToastController,
private route: ActivatedRoute,
private modalCtrl: ModalController,
private alertCtrl: AlertController,
private csvStorageService: CsvStorageService
) { }
async ngOnInit() {
this.route.queryParams.subscribe(params => {
this.highlightedCodigo = params['highlight'] || null;
if (this.highlightedCodigo) {
// Aseguramos que el highlight sea visible (si no es favorito, ir a 'todas')
if (this.viewMode === 'favoritos' && !this.favoritos.includes(this.highlightedCodigo)) {
this.viewMode = 'todas';
}
}
});
this.loadInitialData();
}
async loadInitialData() {
// 0. Sincronizar permisos secuencialmente al iniciar la app para evitar que un popup de Android anule a otro
try {
let notifStatus = await LocalNotifications.checkPermissions();
if (notifStatus.display !== 'granted') {
await LocalNotifications.requestPermissions();
}
} catch (e) {
console.warn('Error pidiendo permisos iniciales', e);
}
this.loading = true;
// 0. Intentar cargar los favoritos desde el archivo CSV seguro (sobrevive desinstalaciones)
const csvFavs = await this.csvStorageService.loadFavoritesFromCsv();
if (csvFavs && csvFavs.length > 0) {
this.favoritos = csvFavs;
this.viewMode = 'favoritos';
// Restaurar también la preferencia rápida nativa para mantener sincronía
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
} else {
// 1. Fallback: cargar los favoritos de las preferencias de Capacitor
const favPref = await Preferences.get({ key: 'favoritos' });
if (favPref.value && JSON.parse(favPref.value).length > 0) {
this.favoritos = JSON.parse(favPref.value);
this.viewMode = 'favoritos';
} else {
// Si es una instalación totalmente fresca (sin config ni CSV auto-encontrado)
await this.promptForRestore();
}
}
// 2. Load from Cache first (Offline mode)
const cached = await this.dataService.getCachedHorarios();
if (cached.length > 0) {
cached.forEach(h => h.horario = this.cleanSchedule(h.horario));
this.horarios = cached;
this.filterHorarios();
this.loading = false; // Show cached data while fetching new
}
// 3. Fetch from Server
this.syncData();
}
syncData(event?: any) {
this.dataService.getHorarios().subscribe({
next: async (data) => {
// filter out invalid rows
const validData = data.filter(h => h.carrera && h.carrera.trim() !== '' && h.codigo && h.codigo.trim() !== 'Código');
// Limpiamos hs/horas de forma recursiva/global
validData.forEach(h => h.horario = this.cleanSchedule(h.horario));
// Sync with cache and notifications
await this.dataService.syncWithCache(validData, this.favoritos);
this.horarios = validData;
this.filterHorarios();
this.loading = false;
this.errorMsg = '';
if (event) event.target.complete();
},
error: async (err) => {
console.error(err);
if (this.horarios.length === 0) {
this.errorMsg = 'Error al cargar los horarios. Verifique su conexión.';
} else {
// If we have cached data, just show a small toast or ignore if it was a background sync
if (event) {
const toast = await this.toastCtrl.create({
message: 'No se pudo actualizar. Mostrando datos guardados.',
duration: 2000,
color: 'warning'
});
toast.present();
}
}
this.loading = false;
if (event) event.target.complete();
}
});
}
async doRefresh(event: any) {
this.syncData(event);
}
async promptForRestore() {
const alert = await this.alertCtrl.create({
header: '¡Datos no encontrados!',
message: 'No encontramos tu configuración de favoritos. Si tienes un archivo CSV de respaldo (aulas_unicaba_datos.csv) en Google Drive, iCloud, o en las descargas de tu dispositivo, puedes restaurarlo manualmente.',
buttons: [
{ text: 'Empezar de cero', role: 'cancel' },
{
text: 'Buscar Respaldo',
handler: async () => {
const imported = await this.csvStorageService.importFavoritesFromFilePicker();
if (imported && imported.length > 0) {
this.favoritos = imported;
this.viewMode = 'favoritos';
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
this.filterHorarios();
const toast = await this.toastCtrl.create({ message: 'Respaldo restaurado con éxito.', duration: 2500, color: 'success' });
toast.present();
} else {
const toast = await this.toastCtrl.create({ message: 'No se pudo leer el archivo o está vacío.', duration: 2500, color: 'danger' });
toast.present();
}
}
}
]
});
await alert.present();
}
filterHorarios() {
let baseList = this.horarios;
if (this.viewMode === 'favoritos') {
// En modo favoritos, ignoramos el buscador de la otra pestaña
baseList = baseList.filter(h => this.favoritos.includes(h.codigo));
} else {
// En modo todas, aplicamos filtro de búsqueda con lógica Fuzzy y Carrera
if (this.searchText && this.searchText.trim() !== '') {
const normalizedSearch = this.normalizeText(this.searchText);
const searchWords = normalizedSearch.split(/\s+/).filter(w => w.length > 0);
baseList = baseList.filter(h => {
const contentToSearch = this.normalizeText(`${h.asignatura} ${h.codigo} ${h.aula} ${h.carrera} ${h.horario}`);
// Cada palabra buscada debe tener al menos un "fuzzy match" en el contenido
return searchWords.every(word => this.fuzzyMatch(contentToSearch, word));
});
}
}
this.horariosFiltrados = baseList;
}
/**
* Determina si hay una coincidencia aproximada (Fuzzy)
*/
private fuzzyMatch(target: string, query: string): boolean {
// 1. Coincidencia exacta o parcial directa (muy rápido)
if (target.includes(query)) return true;
// 2. Si la palabra es corta, no permitimos errores (evita ruido)
if (query.length < 4) return false;
// 3. Algoritmo simple de distancia de caracteres (typos leves)
// Permite 1 error por cada 4 caracteres
const maxErrors = Math.floor(query.length / 4);
const wordsInTarget = target.split(' ');
return wordsInTarget.some(tWord => {
if (Math.abs(tWord.length - query.length) > maxErrors) return false;
return this.getLevenshteinDistance(tWord, query) <= maxErrors;
});
}
private getLevenshteinDistance(a: string, b: string): number {
const matrix = Array.from({ length: a.length + 1 }, () =>
Array.from({ length: b.length + 1 }, (_, i) => i)
);
for (let i = 1; i <= a.length; i++) matrix[i][0] = i;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost
);
}
}
return matrix[a.length][b.length];
}
/**
* Limpia sufijos de hora como "hs" o "horas"
*/
private cleanSchedule(text: string): string {
if (!text) return '';
return text.replace(/([0-9])\s*(hs|horas|h)\b\.?/gi, '$1').trim();
}
/**
* Normaliza texto: minúsculas, quita acentos y caracteres especiales básicos
*/
private normalizeText(text: string): string {
if (!text) return '';
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Quita acentos
.replace(/[:.,\-_]/g, ' ') // Cambia separadores por espacios
.replace(/\s+/g, ' ') // Colapsa espacios
.trim();
}
onSearchChange(event: any) {
this.searchText = event.detail.value;
this.filterHorarios();
}
onViewModeChange() {
this.filterHorarios();
}
isFavorito(codigo: string): boolean {
return this.favoritos.includes(codigo);
}
async toggleFavorito(item: ClaseHorario) {
const isFav = this.isFavorito(item.codigo);
const alert = await this.alertCtrl.create({
header: isFav ? 'Quitar de Favoritos' : 'Agregar a Favoritos',
message: isFav
? `¿Deseas quitar la materia ${item.asignatura} de tus favoritos?`
: `¿Deseas agregar la materia ${item.asignatura} a tus favoritos?`,
buttons: [
{
text: 'Cancelar',
role: 'cancel'
},
{
text: 'Aceptar',
handler: async () => {
if (isFav) {
this.favoritos = this.favoritos.filter(id => id !== item.codigo);
} else {
if (this.favoritos.length >= 5) {
const toast = await this.toastCtrl.create({
message: 'Puedes seleccionar un máximo de 5 materias favoritas',
duration: 3000,
color: 'warning'
});
toast.present();
return;
}
this.favoritos.push(item.codigo);
}
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
await this.csvStorageService.saveFavoritesToCsv(this.favoritos);
this.filterHorarios();
}
}
]
});
await alert.present();
}
// --- Lógica de Compartir ---
async shareItem(item: ClaseHorario) {
// Feedback táctil al tocar el botón
await Haptics.impact({ style: ImpactStyle.Light }).catch(() => { });
let text = `${item.asignatura}\n`;
text += `Aula: ${item.aula}\n`;
text += `${item.dia} - ${item.horario}\n`;
text += `Carrera: ${item.carrera}\n`;
text += `Código: ${item.codigo}`;
if (item.link) {
text += `\n\nEnlace de Aula Virtual: ${item.link}`;
}
text += `\n\nEstos datos fueron enviados por la app Aulas Unicaba.`;
try {
await Share.share({
title: item.asignatura,
text,
dialogTitle: 'Compartir información de materia',
});
} catch (e) {
// El usuario canceló el share o no está disponible
console.warn('Share cancelado:', e);
}
}
openLink(url: string) {
if (!url) return;
// Abre el enlace en el navegador externo o en la app de Zoom según el sistema
window.open(url, '_blank', 'noopener');
}
async openCredencial() {
const modal = await this.modalCtrl.create({
component: CredencialModalComponent
});
await modal.present();
}
}
+179
View File
@@ -0,0 +1,179 @@
import { Injectable } from '@angular/core';
import { CapacitorHttp, HttpResponse } from '@capacitor/core';
import { Preferences } from '@capacitor/preferences';
import * as pdfjsLib from 'pdfjs-dist';
// Set up PDF.js worker. If not set, it falls back to a fake worker, which is fine for small files.
// For best performance without messing with angular.json assets, we can let it use the fake worker
// or set it to null. We'll let it fallback automatically.
@Injectable({
providedIn: 'root'
})
export class CredencialService {
private readonly LOGIN_URL = 'https://campus.udelaciudad.edu.ar/login/index.php';
private readonly CREDENTIAL_URL = 'https://campus.udelaciudad.edu.ar/mod/customcert/view.php?id=209134&downloadown=1';
private readonly PREF_DNI = 'credencial_dni';
private readonly PREF_PASS = 'credencial_pass';
private readonly PREF_IMAGE = 'credencial_base64';
constructor() {}
async getStoredCredentials() {
const dni = await Preferences.get({ key: this.PREF_DNI });
const pass = await Preferences.get({ key: this.PREF_PASS });
return { dni: dni.value, pass: pass.value };
}
async saveCredentials(dni: string, pass: string) {
await Preferences.set({ key: this.PREF_DNI, value: dni });
await Preferences.set({ key: this.PREF_PASS, value: pass });
}
async getCachedImage() {
const img = await Preferences.get({ key: this.PREF_IMAGE });
return img.value;
}
async clearAll() {
await Preferences.remove({ key: this.PREF_DNI });
await Preferences.remove({ key: this.PREF_PASS });
await Preferences.remove({ key: this.PREF_IMAGE });
}
/**
* Executes the full flow: Login -> Scrape Token -> Submit Login -> Download PDF -> Render Base64
*/
async generateCredential(dni: string, pass: string): Promise<string> {
try {
// 1. Initial GET to fetch the login page and token
const initialGet: HttpResponse = await CapacitorHttp.get({
url: this.LOGIN_URL,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
// Simple DOM parsing to extract logintoken
const parser = new DOMParser();
const doc = parser.parseFromString(initialGet.data, 'text/html');
const tokenInput = doc.querySelector('input[name="logintoken"]') as HTMLInputElement;
if (!tokenInput || !tokenInput.value) {
throw new Error('No se pudo obtener el token de seguridad del campus.');
}
const loginToken = tokenInput.value;
// 2. POST to login
// CapacitorHttp handles cookies internally, so the session cookie is saved.
// Send form urlencoded
const params = new URLSearchParams();
params.append('username', dni);
params.append('password', pass);
params.append('logintoken', loginToken);
const loginPost: HttpResponse = await CapacitorHttp.post({
url: this.LOGIN_URL,
data: params.toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
// Verify if login was successful
if (loginPost.data && loginPost.data.includes('loginerrormessage')) {
throw new Error('DNI o Clave incorrectos en el Campus.');
}
// 3. Download the PDF
// We need it as base64 from the native layer to reconstruct the buffer
const pdfRes: HttpResponse = await CapacitorHttp.get({
url: this.CREDENTIAL_URL,
responseType: 'arraybuffer', // Capacitor Http will return base64 string for arraybuffer
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
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}`);
}
// Capacitor returns base64 string when responseType is arraybuffer or blob
const base64Pdf = pdfRes.data;
// Convert base64 PDF to Uint8Array for pdf.js
const pdfData = this.base64ToUint8Array(base64Pdf);
// 4. Render PDF to Base64 Image using pdf.js
const imgBase64 = await this.renderPdfToBase64(pdfData);
// Save to cache securely
await Preferences.set({ key: this.PREF_IMAGE, value: imgBase64 });
// Save credentials for future transparent updates
await this.saveCredentials(dni, pass);
return imgBase64;
} catch (e: any) {
throw new Error(e.message || 'Error desconocido al generar la credencial.');
}
}
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> {
// Configure pdf.js to use the standard worker from local assets.
pdfjsLib.GlobalWorkerOptions.workerSrc = 'assets/pdf.worker.min.mjs';
const loadingTask = pdfjsLib.getDocument({ data: pdfData });
const pdf = await loadingTask.promise;
// Get the first (and only) page
const page = await pdf.getPage(1);
// Render at 2x scale for better resolution (similar to fitz.Matrix(2,2))
const scale = 2.0;
const viewport = page.getViewport({ scale });
// Create a canvas to render the PDF
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
if (!context) throw new Error('Could not create Canvas context');
// Render PDF page into canvas context
const renderContext = {
canvasContext: context,
viewport: viewport,
canvas: canvas
};
await page.render(renderContext).promise;
// Convert canvas to base64 image (PNG)
// format "data:image/png;base64,..."
// We return just the base64 part to match python script, or the full data URI.
// The python script returned the raw base64, but the HTML added 'data:image/png...'.
// Let's return the naked base64 to be consistent.
const dataUri = canvas.toDataURL('image/png');
const base64 = dataUri.split(',')[1];
return base64;
}
}
+126
View File
@@ -0,0 +1,126 @@
import { Injectable } from '@angular/core';
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
import { FilePicker } from '@capawesome/capacitor-file-picker';
@Injectable({
providedIn: 'root'
})
export class CsvStorageService {
private readonly FILE_NAME = 'aulas_unicaba_datos.csv';
constructor() {}
private async ensurePermissions(): Promise<boolean> {
try {
let status = await Filesystem.checkPermissions();
if (status.publicStorage !== 'granted') {
status = await Filesystem.requestPermissions();
}
return status.publicStorage === 'granted';
} catch (e) {
return true;
}
}
async saveFavoritesToCsv(favoritos: string[]) {
try {
const hasPermission = await this.ensurePermissions();
if (!hasPermission) {
console.warn('Permisos denegados para escribir en Documents');
return;
}
let csvContent = 'tipo,valor\n';
favoritos.forEach(codigo => {
csvContent += `favorito,${codigo}\n`;
});
// Intentamos borrar si existe para forzar la actualización transparente en Scoped Storage
try {
await Filesystem.deleteFile({
path: this.FILE_NAME,
directory: Directory.Documents
});
} catch (e) {}
await Filesystem.writeFile({
path: this.FILE_NAME,
data: csvContent,
directory: Directory.Documents,
encoding: Encoding.UTF8
});
console.log('CSV guardado exitosamente en Documents');
} catch (e) {
console.error('Error al guardar el CSV:', e);
}
}
async loadFavoritesFromCsv(): Promise<string[] | null> {
try {
const hasPermission = await this.ensurePermissions();
if (!hasPermission) return null;
const contents = await Filesystem.readFile({
path: this.FILE_NAME,
directory: Directory.Documents,
encoding: Encoding.UTF8
});
if (!contents.data) return null;
const lines = contents.data.toString().split('\n');
const favs: string[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('favorito,')) {
const parts = line.split(',');
if (parts.length > 1 && parts[1]) {
favs.push(parts[1]);
}
}
}
return favs.length > 0 ? favs : null;
} catch (e) {
return null;
}
}
async importFavoritesFromFilePicker(): Promise<string[] | null> {
try {
const result = await FilePicker.pickFiles({
types: ['text/csv', 'text/comma-separated-values', '.csv'],
readData: true
});
const file = result.files[0];
if (!file || !file.data) return null;
const text = decodeURIComponent(escape(window.atob(file.data)));
const lines = text.split('\n');
const favs: string[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('favorito,')) {
const parts = line.split(',');
if (parts.length > 1 && parts[1]) {
favs.push(parts[1]);
}
}
}
if (favs.length > 0) {
await this.saveFavoritesToCsv(favs);
return favs;
}
return null;
} catch(e) {
console.warn('File picker cancelado o falló', e);
return null;
}
}
}
+16
View File
@@ -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();
});
});
+245
View File
@@ -0,0 +1,245 @@
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 } from '@capacitor/local-notifications';
export interface ClaseHorario {
codigo: string;
asignatura: string;
aula: string;
carrera: string;
horario: string;
dia?: string;
link?: 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 [];
}
}
private async checkRoomChanges(oldData: ClaseHorario[], newData: ClaseHorario[], favoritos: string[]) {
for (const item of newData) {
if (favoritos.includes(item.codigo)) {
const oldItem = oldData.find(o =>
o.codigo === item.codigo &&
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;
}
}
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

+1
View File
@@ -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

+3
View File
@@ -0,0 +1,3 @@
export const environment = {
production: true
};
+16
View File
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';
platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.log(err));
+46
View File
@@ -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"
}
]
}
+55
View File
@@ -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(),
);
+17
View File
@@ -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;
}
+6
View File
@@ -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;