Primera versión estable v1.1.5 - Aulas UNICABA
@@ -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 { }
|
||||
@@ -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,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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,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 {}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
|
||||
import { HomePage } from './home.page';
|
||||
|
||||
describe('HomePage', () => {
|
||||
let component: HomePage;
|
||||
let fixture: ComponentFixture<HomePage>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [HomePage],
|
||||
imports: [IonicModule.forRoot()]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HomePage);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="350" height="140" xmlns="http://www.w3.org/2000/svg" style="background:#f6f7f9"><g fill="none" fill-rule="evenodd"><path fill="#F04141" style="mix-blend-mode:multiply" d="M61.905-34.23l96.194 54.51-66.982 54.512L22 34.887z"/><circle fill="#10DC60" style="mix-blend-mode:multiply" cx="155.5" cy="135.5" r="57.5"/><path fill="#3880FF" style="mix-blend-mode:multiply" d="M208.538 9.513l84.417 15.392L223.93 93.93z"/><path fill="#FFCE00" style="mix-blend-mode:multiply" d="M268.625 106.557l46.332-26.75 46.332 26.75v53.5l-46.332 26.75-46.332-26.75z"/><circle fill="#7044FF" style="mix-blend-mode:multiply" cx="299.5" cy="9.5" r="38.5"/><rect fill="#11D3EA" style="mix-blend-mode:multiply" transform="rotate(-60 148.47 37.886)" x="143.372" y="-7.056" width="10.196" height="89.884" rx="5.098"/><path d="M-25.389 74.253l84.86 8.107c5.498.525 9.53 5.407 9.004 10.905a10 10 0 0 1-.057.477l-12.36 85.671a10.002 10.002 0 0 1-11.634 8.42l-86.351-15.226c-5.44-.959-9.07-6.145-8.112-11.584l13.851-78.551a10 10 0 0 1 10.799-8.219z" fill="#7044FF" style="mix-blend-mode:multiply"/><circle fill="#0CD1E8" style="mix-blend-mode:multiply" cx="273.5" cy="106.5" r="20.5"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
export const environment = {
|
||||
production: true
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
// This file can be replaced during build by using the `fileReplacements` array.
|
||||
// `ng build` replaces `environment.ts` with `environment.prod.ts`.
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
export const environment = {
|
||||
production: false
|
||||
};
|
||||
|
||||
/*
|
||||
* For easier debugging in development mode, you can import the following file
|
||||
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
|
||||
*
|
||||
* This import should be commented out in production mode because it will have a negative impact
|
||||
* on performance if an error is thrown.
|
||||
*/
|
||||
// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* App Global CSS
|
||||
* ----------------------------------------------------------------------------
|
||||
* Put style rules here that you want to apply globally. These styles are for
|
||||
* the entire app and not just one component. Additionally, this file can be
|
||||
* used as an entry point to import other CSS/Sass files to be included in the
|
||||
* output CSS.
|
||||
* For more information on global stylesheets, visit the documentation:
|
||||
* https://ionicframework.com/docs/layout/global-stylesheets
|
||||
*/
|
||||
|
||||
/* Core CSS required for Ionic components to work properly */
|
||||
@import "@ionic/angular/css/core.css";
|
||||
|
||||
/* Basic CSS for apps built with Ionic */
|
||||
@import "@ionic/angular/css/normalize.css";
|
||||
@import "@ionic/angular/css/structure.css";
|
||||
@import "@ionic/angular/css/typography.css";
|
||||
@import "@ionic/angular/css/display.css";
|
||||
|
||||
/* Optional CSS utils that can be commented out */
|
||||
@import "@ionic/angular/css/padding.css";
|
||||
@import "@ionic/angular/css/float-elements.css";
|
||||
@import "@ionic/angular/css/text-alignment.css";
|
||||
@import "@ionic/angular/css/text-transformation.css";
|
||||
@import "@ionic/angular/css/flex-utils.css";
|
||||
|
||||
/**
|
||||
* Ionic Dark Mode
|
||||
* -----------------------------------------------------
|
||||
* For more info, please see:
|
||||
* https://ionicframework.com/docs/theming/dark-mode
|
||||
*/
|
||||
|
||||
/* @import "@ionic/angular/css/palettes/dark.always.css"; */
|
||||
/* @import "@ionic/angular/css/palettes/dark.class.css"; */
|
||||
@import "@ionic/angular/css/palettes/dark.system.css";
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Cartelera Aulas UNICABA</title>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
|
||||
<link rel="icon" type="image/png" href="assets/icon/favicon.png" />
|
||||
|
||||
<!-- add to homescreen for ios -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.log(err));
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/icons/icon-48.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "48x48",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-72.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "72x72",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-96.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "96x96",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-128.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "128x128",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-192.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-256.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "assets/icons/icon-512.webp",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* This file includes polyfills needed by Angular and is loaded before the app.
|
||||
* You can add your own extra polyfills to this file.
|
||||
*
|
||||
* This file is divided into 2 sections:
|
||||
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
|
||||
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
|
||||
* file.
|
||||
*
|
||||
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
|
||||
* automatically update themselves. This includes recent versions of Safari, Chrome (including
|
||||
* Opera), Edge on the desktop, and iOS and Chrome on mobile.
|
||||
*
|
||||
* Learn more in https://angular.io/guide/browser-support
|
||||
*/
|
||||
|
||||
/***************************************************************************************************
|
||||
* BROWSER POLYFILLS
|
||||
*/
|
||||
|
||||
/**
|
||||
* By default, zone.js will patch all possible macroTask and DomEvents
|
||||
* user can disable parts of macroTask/DomEvents patch by setting following flags
|
||||
* because those flags need to be set before `zone.js` being loaded, and webpack
|
||||
* will put import in the top of bundle, so user need to create a separate file
|
||||
* in this directory (for example: zone-flags.ts), and put the following flags
|
||||
* into that file, and then add the following code before importing zone.js.
|
||||
* import './zone-flags';
|
||||
*
|
||||
* The flags allowed in zone-flags.ts are listed here.
|
||||
*
|
||||
* The following flags will work for all browsers.
|
||||
*
|
||||
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
|
||||
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
|
||||
* (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
|
||||
*
|
||||
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
|
||||
* with the following flag, it will bypass `zone.js` patch for IE/Edge
|
||||
*
|
||||
* (window as any).__Zone_enable_cross_context_check = true;
|
||||
*
|
||||
*/
|
||||
|
||||
import './zone-flags';
|
||||
|
||||
/***************************************************************************************************
|
||||
* Zone JS is required by default for Angular itself.
|
||||
*/
|
||||
import 'zone.js'; // Included with Angular CLI.
|
||||
|
||||
|
||||
/***************************************************************************************************
|
||||
* APPLICATION IMPORTS
|
||||
*/
|
||||
@@ -0,0 +1,14 @@
|
||||
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
|
||||
|
||||
import 'zone.js/testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting
|
||||
} from '@angular/platform-browser-dynamic/testing';
|
||||
|
||||
// First, initialize the Angular testing environment.
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting(),
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
:root {
|
||||
/** primary **/
|
||||
--ion-color-primary: #B43E8E;
|
||||
--ion-color-primary-rgb: 180, 62, 142;
|
||||
--ion-color-primary-contrast: #ffffff;
|
||||
--ion-color-primary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-primary-shade: #9e367d;
|
||||
--ion-color-primary-tint: #bc5199;
|
||||
|
||||
/** secondary **/
|
||||
--ion-color-secondary: #b43e8e;
|
||||
--ion-color-secondary-rgb: 180, 62, 142;
|
||||
--ion-color-secondary-contrast: #ffffff;
|
||||
--ion-color-secondary-contrast-rgb: 255, 255, 255;
|
||||
--ion-color-secondary-shade: #9e367d;
|
||||
--ion-color-secondary-tint: #bc5199;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Prevents Angular change detection from
|
||||
* running with certain Web Component callbacks
|
||||
*/
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
(window as any).__Zone_disable_customElements = true;
|
||||