feat: actualización a v1.1.9 con Agenda interactiva y correcciones visuales
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary">
|
||||
<ion-title>Agenda Académica</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">Cerrar</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding" color="light">
|
||||
<div class="materia-info">
|
||||
<h2>{{ materia.asignatura }}</h2>
|
||||
<p>Código: {{ materia.codigo }}</p>
|
||||
</div>
|
||||
|
||||
<ion-button expand="block" (click)="abrirEditor()" class="add-btn">
|
||||
<ion-icon name="add-circle-outline" slot="start"></ion-icon>
|
||||
Agendar Nuevo Evento
|
||||
</ion-button>
|
||||
|
||||
<div class="empty-state" *ngIf="eventos.length === 0">
|
||||
<ion-icon name="calendar-clear-outline"></ion-icon>
|
||||
<p>No hay eventos programados.</p>
|
||||
</div>
|
||||
|
||||
<ion-list *ngIf="eventos.length > 0" class="agenda-list">
|
||||
<ion-item *ngFor="let ev of eventos" button (click)="abrirEditor(ev)" lines="full" class="agenda-item">
|
||||
<div class="item-content">
|
||||
<div class="item-header">
|
||||
<ion-badge color="secondary">{{ ev.tipo }}</ion-badge>
|
||||
</div>
|
||||
<div class="item-details">
|
||||
<p class="fecha-hora">
|
||||
<ion-icon name="calendar-outline"></ion-icon> {{ formatFecha(ev.fecha) }}
|
||||
<ion-icon name="time-outline" class="icon-space"></ion-icon> {{ formatHora(ev.fecha) }} hs
|
||||
</p>
|
||||
<p class="aula" *ngIf="ev.aula">
|
||||
<ion-icon name="location-outline"></ion-icon> Aula: {{ ev.aula }}
|
||||
</p>
|
||||
<p class="notas" *ngIf="ev.notas">
|
||||
{{ ev.notas }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ion-icon name="chevron-forward-outline" slot="end" color="medium"></ion-icon>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,77 @@
|
||||
.materia-info {
|
||||
margin-bottom: 20px;
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--ion-color-medium);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
margin-bottom: 20px;
|
||||
--border-radius: 8px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
color: var(--ion-color-medium);
|
||||
|
||||
ion-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.agenda-list {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.agenda-item {
|
||||
--background: var(--ion-card-background, #ffffff);
|
||||
--border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
|
||||
.item-content {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
p {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ion-text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
ion-icon {
|
||||
margin-right: 6px;
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
|
||||
&.notas {
|
||||
color: var(--ion-color-medium);
|
||||
font-style: italic;
|
||||
margin-top: 8px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-space {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
|
||||
import { AgendaModalComponent } from './agenda-modal.component';
|
||||
|
||||
describe('AgendaModalComponent', () => {
|
||||
let component: AgendaModalComponent;
|
||||
let fixture: ComponentFixture<AgendaModalComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AgendaModalComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AgendaModalComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { IonicModule, ModalController } from '@ionic/angular';
|
||||
import { EventoAcademico, ClaseHorario } from '../../services/data';
|
||||
import { EventosService } from '../../services/eventos.service';
|
||||
import { EventoModalComponent } from '../evento-modal/evento-modal.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-agenda-modal',
|
||||
templateUrl: './agenda-modal.component.html',
|
||||
styleUrls: ['./agenda-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, IonicModule]
|
||||
})
|
||||
export class AgendaModalComponent implements OnInit {
|
||||
@Input() materia!: ClaseHorario;
|
||||
eventos: EventoAcademico[] = [];
|
||||
|
||||
constructor(
|
||||
private modalCtrl: ModalController,
|
||||
private eventosService: EventosService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.cargarEventos();
|
||||
}
|
||||
|
||||
ionViewWillEnter() {
|
||||
this.cargarEventos();
|
||||
}
|
||||
|
||||
cargarEventos() {
|
||||
this.eventos = this.eventosService.getEventosByMateria(this.materia.codigo)
|
||||
.sort((a, b) => new Date(a.fecha).getTime() - new Date(b.fecha).getTime());
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss();
|
||||
}
|
||||
|
||||
formatFecha(fechaIso: string): string {
|
||||
const d = new Date(fechaIso);
|
||||
return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
formatHora(fechaIso: string): string {
|
||||
const d = new Date(fechaIso);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
async abrirEditor(evento?: EventoAcademico) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: EventoModalComponent,
|
||||
componentProps: {
|
||||
materiaCodigo: this.materia.codigo,
|
||||
materiaNombre: this.materia.asignatura,
|
||||
eventoExistente: evento
|
||||
}
|
||||
});
|
||||
|
||||
await modal.present();
|
||||
|
||||
const { role } = await modal.onWillDismiss();
|
||||
if (role === 'saved' || role === 'deleted') {
|
||||
this.cargarEventos();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<ion-header>
|
||||
<ion-toolbar color="primary">
|
||||
<ion-title>{{ isEdit ? 'Editar' : 'Programar' }} Hito</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">Cerrar</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<div class="materia-info">
|
||||
<small>Materia:</small>
|
||||
<h3>{{ materiaNombre }}</h3>
|
||||
</div>
|
||||
|
||||
<ion-list lines="inset" class="compact-list">
|
||||
<ion-item class="input-item">
|
||||
<ion-label position="stacked">Tipo e Hito</ion-label>
|
||||
<ion-select [(ngModel)]="tipo" interface="popover">
|
||||
<ion-select-option value="Parcial 1">Parcial 1</ion-select-option>
|
||||
<ion-select-option value="Parcial 2">Parcial 2</ion-select-option>
|
||||
<ion-select-option value="TP 1">Trabajo Práctico 1</ion-select-option>
|
||||
<ion-select-option value="TP 2">Trabajo Práctico 2</ion-select-option>
|
||||
<ion-select-option value="TP 3">Trabajo Práctico 3</ion-select-option>
|
||||
<ion-select-option value="Recu. P1">Recuperatorio Parcial 1</ion-select-option>
|
||||
<ion-select-option value="Recu. P2">Recuperatorio Parcial 2</ion-select-option>
|
||||
<ion-select-option value="Recu. TP 1">Recuperatorio TP 1</ion-select-option>
|
||||
<ion-select-option value="Recu. TP 2">Recuperatorio TP 2</ion-select-option>
|
||||
<ion-select-option value="Recu. TP 3">Recuperatorio TP 3</ion-select-option>
|
||||
<ion-select-option value="Final">Final</ion-select-option>
|
||||
<ion-select-option value="Otro">Otro</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
|
||||
<ion-item class="input-item">
|
||||
<ion-label position="stacked">Fecha y Hora</ion-label>
|
||||
<ion-datetime-button datetime="datetime"></ion-datetime-button>
|
||||
<ion-modal [keepContentsMounted]="true">
|
||||
<ng-template>
|
||||
<ion-datetime id="datetime" [(ngModel)]="fecha" presentation="date-time" [showDefaultButtons]="true"
|
||||
doneText="OK" cancelText="Cancelar"></ion-datetime>
|
||||
</ng-template>
|
||||
</ion-modal>
|
||||
</ion-item>
|
||||
|
||||
<ion-grid class="ion-no-padding">
|
||||
<ion-row>
|
||||
<ion-col size="6">
|
||||
<ion-item class="input-item side-item">
|
||||
<ion-label position="stacked">Aula / Zoom</ion-label>
|
||||
<ion-input type="text" [(ngModel)]="aula" placeholder="Ej: 102"></ion-input>
|
||||
</ion-item>
|
||||
</ion-col>
|
||||
<ion-col size="6">
|
||||
<ion-item class="input-item side-item">
|
||||
<ion-label position="stacked">Link</ion-label>
|
||||
<ion-input type="url" [(ngModel)]="link" placeholder="https://"></ion-input>
|
||||
</ion-item>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
|
||||
<ion-item class="input-item">
|
||||
<ion-label position="stacked">Notas</ion-label>
|
||||
<ion-textarea [(ngModel)]="notas" placeholder="Breve descripción..." rows="2"></ion-textarea>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
|
||||
<div class="actions-container">
|
||||
<ion-button expand="block" color="secondary" (click)="guardar()" class="main-btn">
|
||||
{{ isEdit ? 'Actualizar Agenda' : 'Agendar Hito' }}
|
||||
</ion-button>
|
||||
|
||||
<ion-button *ngIf="isEdit" expand="block" fill="clear" color="danger" (click)="eliminar()" class="delete-btn">
|
||||
<ion-icon name="trash-outline" slot="start"></ion-icon>
|
||||
Eliminar Evento
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,52 @@
|
||||
.materia-info {
|
||||
margin-top: 10px;
|
||||
h3 {
|
||||
margin: 5px 0 20px;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.input-item {
|
||||
--padding-start: 10px;
|
||||
--padding-end: 10px;
|
||||
--padding-top: 5px;
|
||||
--padding-bottom: 5px;
|
||||
--min-height: 45px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--ion-color-light);
|
||||
|
||||
ion-label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px !important;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.side-item {
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
|
||||
ion-datetime-button {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.actions-container {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.main-btn {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
--color: var(--ion-color-danger);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { IonicModule, ModalController } from '@ionic/angular';
|
||||
import { EventoAcademico } from '../../services/data';
|
||||
import { EventosService } from '../../services/eventos.service';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-evento-modal',
|
||||
templateUrl: './evento-modal.component.html',
|
||||
styleUrls: ['./evento-modal.component.scss'],
|
||||
standalone: true,
|
||||
imports: [CommonModule, IonicModule, FormsModule]
|
||||
})
|
||||
export class EventoModalComponent implements OnInit {
|
||||
@Input() materiaCodigo!: string;
|
||||
@Input() materiaNombre!: string;
|
||||
@Input() eventoExistente?: EventoAcademico;
|
||||
|
||||
tipo: 'Parcial' | 'TP' | 'Final' | 'Otro' = 'Parcial';
|
||||
fecha: string = new Date().toISOString();
|
||||
aula: string = '';
|
||||
link: string = '';
|
||||
notas: string = '';
|
||||
|
||||
isEdit: boolean = false;
|
||||
|
||||
constructor(
|
||||
private modalCtrl: ModalController,
|
||||
private eventosService: EventosService
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
if (this.eventoExistente) {
|
||||
this.isEdit = true;
|
||||
this.tipo = this.eventoExistente.tipo;
|
||||
this.fecha = this.eventoExistente.fecha;
|
||||
this.aula = this.eventoExistente.aula || '';
|
||||
this.link = this.eventoExistente.link || '';
|
||||
this.notas = this.eventoExistente.notas || '';
|
||||
}
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss();
|
||||
}
|
||||
|
||||
async guardar() {
|
||||
const nuevoEvento: EventoAcademico = {
|
||||
id: this.isEdit ? this.eventoExistente!.id : Date.now().toString(),
|
||||
materiaCodigo: this.materiaCodigo,
|
||||
tipo: this.tipo,
|
||||
fecha: this.fecha,
|
||||
aula: this.aula,
|
||||
link: this.link,
|
||||
notas: this.notas
|
||||
};
|
||||
|
||||
await this.eventosService.saveEvento(nuevoEvento);
|
||||
this.modalCtrl.dismiss(nuevoEvento, 'saved');
|
||||
}
|
||||
|
||||
async eliminar() {
|
||||
if (this.eventoExistente) {
|
||||
await this.eventosService.deleteEvento(this.eventoExistente.id);
|
||||
this.modalCtrl.dismiss(null, 'deleted');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { HomePage } from './home.page';
|
||||
|
||||
import { HomePageRoutingModule } from './home-routing.module';
|
||||
import { CredencialModalComponent } from '../components/credencial-modal/credencial-modal.component';
|
||||
import { EventoModalComponent } from '../components/evento-modal/evento-modal.component';
|
||||
|
||||
|
||||
|
||||
@NgModule({
|
||||
@@ -14,7 +16,8 @@ import { CredencialModalComponent } from '../components/credencial-modal/credenc
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
HomePageRoutingModule,
|
||||
CredencialModalComponent
|
||||
CredencialModalComponent,
|
||||
EventoModalComponent
|
||||
],
|
||||
declarations: [HomePage]
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</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">
|
||||
@@ -71,7 +71,7 @@
|
||||
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>
|
||||
<p class="ion-text-center"><small>Versión 1.1.9 - 26/03/2026</small></p>
|
||||
</ion-content>
|
||||
</ng-template>
|
||||
</ion-modal>
|
||||
@@ -82,6 +82,38 @@
|
||||
refreshingText="Actualizando..."></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<!-- Banner: Próxima Clase -->
|
||||
<div class="proxima-clase-container" *ngIf="viewMode === 'favoritos' && proximaClase && !loading">
|
||||
<ion-card class="proxima-clase-card" [class.urgente]="proximaClase.diffMs > 0 && proximaClase.diffMs <= 1800000">
|
||||
<ion-card-content (click)="verAgendaMateria(proximaClase.item)">
|
||||
<div class="proxima-header">
|
||||
<div class="proxima-label">
|
||||
<ion-icon name="time-outline"></ion-icon>
|
||||
<span>{{ proximaClase.label }}</span>
|
||||
</div>
|
||||
<ion-chip [color]="countdownColor(proximaClase.diffMs)" class="countdown-chip"
|
||||
[class.pulse-chip]="proximaClase.diffMs > 0 && proximaClase.diffMs <= 1800000">
|
||||
<ion-icon name="alarm-outline"></ion-icon>
|
||||
<ion-label>{{ formatCountdown(proximaClase.diffMs) }}</ion-label>
|
||||
</ion-chip>
|
||||
</div>
|
||||
|
||||
<p class="proxima-asignatura">{{ proximaClase.item.asignatura }}</p>
|
||||
<div class="proxima-detalles">
|
||||
<span class="proxima-aula">
|
||||
<ion-icon name="location-outline"></ion-icon>
|
||||
Aula {{ proximaClase.item.aula }}
|
||||
</span>
|
||||
<span class="proxima-hora">
|
||||
<ion-icon name="calendar-outline"></ion-icon>
|
||||
{{ proximaClase.item.dia }} {{ proximaClase.item.horario }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</div>
|
||||
|
||||
<div class="loading-container" *ngIf="loading">
|
||||
<ion-spinner name="crescent" color="primary"></ion-spinner>
|
||||
<p>Cargando cartelera...</p>
|
||||
@@ -102,26 +134,43 @@
|
||||
<ion-card-title>{{ item.asignatura }}</ion-card-title>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<!-- Compartir siempre visible -->
|
||||
<ion-icon class="share-icon" name="share-social-outline" color="medium"
|
||||
(click)="shareItem(item)"></ion-icon>
|
||||
<ion-icon class="fav-icon" [name]="isFavorito(item.codigo) ? 'star' : 'star-outline'"
|
||||
[color]="isFavorito(item.codigo) ? 'warning' : 'medium'" (click)="toggleFavorito(item)"></ion-icon>
|
||||
|
||||
<!-- Icono condicional: Hamburguesa en favoritos, Estrella en cartelera -->
|
||||
<ion-icon class="menu-icon"
|
||||
[name]="viewMode === 'favoritos' ? 'menu-outline' : (isFavorito(item.codigo) ? 'star' : 'star-outline')"
|
||||
[color]="(viewMode === 'todas' && isFavorito(item.codigo)) ? 'warning' : 'medium'"
|
||||
(click)="openMateriaActions(item)"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Indicador de Próximo Evento (Agenda) -->
|
||||
<div class="evento-badge-container" *ngIf="getProximoEvento(item.codigo) as proximo">
|
||||
<ion-badge [color]="isEventoUrgente(proximo.fecha) ? 'danger' : 'secondary'" class="evento-badge">
|
||||
<ion-icon [name]="proximo.tipo === 'TP' ? 'document-text-outline' : 'calendar-outline'"></ion-icon>
|
||||
{{ proximo.tipo }}: {{ formatFechaEvento(proximo.fecha) }}
|
||||
</ion-badge>
|
||||
</div>
|
||||
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<ion-card-content (click)="verAgendaMateria(item)">
|
||||
<p><strong>Código:</strong> {{ item.codigo }}</p>
|
||||
<div class="aula-container">
|
||||
<span class="aula-tag">{{ item.aula }}</span>
|
||||
</div>
|
||||
<p class="schedule-text">{{ item.dia }} - {{ item.horario }}</p>
|
||||
<div *ngIf="item.link" class="link-container">
|
||||
<ion-button fill="outline" color="primary" size="small" expand="block" (click)="openLink(item.link!)">
|
||||
<ion-button fill="outline" color="primary" size="small" expand="block"
|
||||
(click)="openLink(item.link!); $event.stopPropagation()">
|
||||
<ion-icon slot="start" name="videocam-outline"></ion-icon>
|
||||
Ingresar a Clase Virtual
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-card-content>
|
||||
|
||||
</ion-card>
|
||||
</ion-list>
|
||||
|
||||
@@ -133,9 +182,12 @@
|
||||
|
||||
<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-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>
|
||||
<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>
|
||||
|
||||
+347
-212
@@ -1,212 +1,347 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
.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,
|
||||
.menu-icon {
|
||||
font-size: 1.6rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.link-container {
|
||||
margin-top: 10px;
|
||||
|
||||
ion-button {
|
||||
--border-radius: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-text {
|
||||
color: #7B2049;
|
||||
/* Secondary Uva color */
|
||||
font-size: 0.98rem !important;
|
||||
font-weight: bold !important;
|
||||
margin-top: 8px !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.highlight-card {
|
||||
border: 2px solid var(--ion-color-secondary) !important;
|
||||
animation: pulse-border 1s alternate infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
from {
|
||||
box-shadow: 0 0 5px var(--ion-color-secondary);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 0 20px var(--ion-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode overwrites for custom colored text */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.aula-container .aula-tag {
|
||||
color: #ae7fff; /* Lighter vivid purple for dark background */
|
||||
}
|
||||
|
||||
.schedule-text {
|
||||
color: #ff8ab6 !important; /* Lighter magenta for dark background */
|
||||
}
|
||||
|
||||
.highlight-card {
|
||||
border-color: var(--ion-color-primary) !important;
|
||||
animation: pulse-border-dark 1s alternate infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-border-dark {
|
||||
from {
|
||||
box-shadow: 0 0 5px var(--ion-color-primary);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 0 20px var(--ion-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.proxima-clase-card {
|
||||
background: linear-gradient(135deg, #1e2a4a, #1a1f3c) !important;
|
||||
border-color: rgba(var(--ion-color-primary-rgb), 0.6) !important;
|
||||
|
||||
|
||||
|
||||
.proxima-label { color: rgba(255, 255, 255, 0.6); }
|
||||
.proxima-asignatura { color: #fff; }
|
||||
.proxima-detalles { color: rgba(255, 255, 255, 0.75); }
|
||||
}
|
||||
|
||||
.evento-badge {
|
||||
--background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||
color: var(--ion-color-secondary-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Hitos Académicos (Agenda) ──────────────────────────────────────
|
||||
|
||||
.evento-badge-container {
|
||||
margin-top: 5px;
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
|
||||
.evento-badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
|
||||
ion-icon {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── Banner: Próxima Clase ───────────────────────────────────────────
|
||||
|
||||
.proxima-clase-container {
|
||||
padding: 8px 12px 0;
|
||||
}
|
||||
|
||||
.proxima-clase-card {
|
||||
margin: 0;
|
||||
border-radius: 14px;
|
||||
border: 1.5px solid rgba(var(--ion-color-primary-rgb), 0.35);
|
||||
background: linear-gradient(135deg, #eef2ff, #f5f0ff);
|
||||
box-shadow: 0 4px 18px rgba(var(--ion-color-primary-rgb), 0.15);
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
|
||||
ion-card-content {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
&.urgente {
|
||||
border-color: rgba(var(--ion-color-danger-rgb), 0.5);
|
||||
background: linear-gradient(135deg, #fff0f3, #ffe8ed);
|
||||
box-shadow: 0 4px 18px rgba(var(--ion-color-danger-rgb), 0.18);
|
||||
animation: pulse-urgente 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-urgente {
|
||||
0%, 100% { box-shadow: 0 4px 18px rgba(var(--ion-color-danger-rgb), 0.15); }
|
||||
50% { box-shadow: 0 4px 28px rgba(var(--ion-color-danger-rgb), 0.35); }
|
||||
}
|
||||
|
||||
.proxima-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.proxima-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--ion-color-medium);
|
||||
|
||||
ion-icon { font-size: 1rem; }
|
||||
}
|
||||
}
|
||||
|
||||
.countdown-chip {
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
height: 28px;
|
||||
--padding-start: 10px;
|
||||
--padding-end: 10px;
|
||||
|
||||
ion-icon { margin-right: 4px; }
|
||||
}
|
||||
|
||||
@keyframes pulse-chip {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.75; transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.pulse-chip {
|
||||
animation: pulse-chip 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.proxima-asignatura {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-dark);
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.proxima-detalles {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.88rem;
|
||||
color: var(--ion-color-medium-shade);
|
||||
font-weight: 500;
|
||||
|
||||
ion-icon { font-size: 1rem; flex-shrink: 0; }
|
||||
}
|
||||
}␍
|
||||
+301
-20
@@ -1,6 +1,7 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ToastController, ModalController, AlertController } from '@ionic/angular';
|
||||
import { DataService, ClaseHorario } from '../services/data';
|
||||
import { ToastController, ModalController, AlertController, ActionSheetController, IonicSafeString } from '@ionic/angular';
|
||||
|
||||
import { DataService, ClaseHorario, EventoAcademico } from '../services/data';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { Filesystem } from '@capacitor/filesystem';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
@@ -9,6 +10,17 @@ 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';
|
||||
import { EventosService } from '../services/eventos.service';
|
||||
import { EventoModalComponent } from '../components/evento-modal/evento-modal.component';
|
||||
import { AgendaModalComponent } from '../components/agenda-modal/agenda-modal.component';
|
||||
|
||||
|
||||
export interface ProximaClaseInfo {
|
||||
item: ClaseHorario;
|
||||
diffMs: number;
|
||||
label: 'Próxima Clase' | 'Clase en Curso';
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
@@ -30,15 +42,30 @@ export class HomePage implements OnInit {
|
||||
errorMsg: string = '';
|
||||
isInfoModalOpen: boolean = false;
|
||||
|
||||
proximaClase: ProximaClaseInfo | null = null;
|
||||
private countdownTimer: any = null;
|
||||
|
||||
eventos: EventoAcademico[] = [];
|
||||
|
||||
|
||||
// Mapa de días en español al getDay() de JS (0=Dom)
|
||||
private readonly DIAS_DOW: Record<string, number> = {
|
||||
'Lunes': 1, 'Martes': 2, 'Miércoles': 3, 'Miercoles': 3,
|
||||
'Jueves': 4, 'Viernes': 5, 'Sábado': 6, 'Sabado': 6, 'Domingo': 0
|
||||
};
|
||||
|
||||
constructor(
|
||||
private dataService: DataService,
|
||||
private toastCtrl: ToastController,
|
||||
private route: ActivatedRoute,
|
||||
private modalCtrl: ModalController,
|
||||
private alertCtrl: AlertController,
|
||||
private csvStorageService: CsvStorageService
|
||||
private csvStorageService: CsvStorageService,
|
||||
private actionSheetCtrl: ActionSheetController,
|
||||
private eventosService: EventosService
|
||||
) { }
|
||||
|
||||
|
||||
async ngOnInit() {
|
||||
this.route.queryParams.subscribe(params => {
|
||||
this.highlightedCodigo = params['highlight'] || null;
|
||||
@@ -65,25 +92,31 @@ export class HomePage implements OnInit {
|
||||
|
||||
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;
|
||||
// 0. Intentar cargar los datos de usuario desde el archivo CSV seguro
|
||||
const userData = await this.csvStorageService.loadUserDataFromCsv();
|
||||
if (userData) {
|
||||
this.favoritos = userData.favoritos;
|
||||
// Sincronizar eventos cargados desde el CSV con el servicio de eventos
|
||||
for (const ev of userData.eventos) {
|
||||
await this.eventosService.saveEvento(ev);
|
||||
}
|
||||
this.eventos = await this.eventosService.loadEventos();
|
||||
|
||||
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
|
||||
// 1. Fallback: cargar los favoritos de las preferencias de Capacitor si no hay CSV
|
||||
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();
|
||||
}
|
||||
this.eventos = await this.eventosService.loadEventos();
|
||||
}
|
||||
|
||||
|
||||
// 2. Load from Cache first (Offline mode)
|
||||
const cached = await this.dataService.getCachedHorarios();
|
||||
if (cached.length > 0) {
|
||||
@@ -114,8 +147,16 @@ export class HomePage implements OnInit {
|
||||
this.loading = false;
|
||||
this.errorMsg = '';
|
||||
|
||||
// Programar recordatorios de clase 30 min antes
|
||||
const favItems = this.horarios.filter(h => this.favoritos.includes(h.codigo));
|
||||
this.dataService.scheduleClassReminders(favItems);
|
||||
|
||||
// Auto-reparación/Actualización del CSV de respaldo
|
||||
this.csvStorageService.saveUserDataToCsv(this.favoritos, this.eventos);
|
||||
|
||||
if (event) event.target.complete();
|
||||
},
|
||||
|
||||
error: async (err) => {
|
||||
console.error(err);
|
||||
if (this.horarios.length === 0) {
|
||||
@@ -156,7 +197,7 @@ export class HomePage implements OnInit {
|
||||
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 {
|
||||
@@ -174,18 +215,16 @@ export class HomePage implements OnInit {
|
||||
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));
|
||||
this.actualizarProximaClase();
|
||||
} else {
|
||||
// En modo todas, aplicamos filtro de búsqueda con lógica Fuzzy y Carrera
|
||||
this.proximaClase = null;
|
||||
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));
|
||||
});
|
||||
}
|
||||
@@ -194,6 +233,111 @@ export class HomePage implements OnInit {
|
||||
this.horariosFiltrados = baseList;
|
||||
}
|
||||
|
||||
private actualizarProximaClase() {
|
||||
if (!this.horarios || this.favoritos.length === 0) {
|
||||
this.proximaClase = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const ahora = new Date();
|
||||
const hoyIdx = ahora.getDay(); // 0-6
|
||||
const horaActualStr = ahora.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
|
||||
let candidata: ProximaClaseInfo | null = null;
|
||||
|
||||
// 1. Buscar si hay alguna clase en curso AHORA
|
||||
const favsHoy = this.horarios.filter(h => this.isFavorito(h.codigo) && this.DIAS_DOW[h.dia || ''] === hoyIdx);
|
||||
|
||||
for (const item of favsHoy) {
|
||||
const parts = (item.horario || '').split('-');
|
||||
if (parts.length >= 2) {
|
||||
const mInicio = parts[0].match(/(\d{1,2}):(\d{2})/);
|
||||
const mFin = parts[1].match(/(\d{1,2}):(\d{2})/);
|
||||
if (mInicio && mFin) {
|
||||
const tInicio = `${mInicio[1].padStart(2, '0')}:${mInicio[2]}`;
|
||||
const tFin = `${mFin[1].padStart(2, '0')}:${mFin[2]}`;
|
||||
if (horaActualStr >= tInicio && horaActualStr <= tFin) {
|
||||
this.proximaClase = { item, diffMs: 0, label: 'Clase en Curso' };
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Si no hay en curso, buscar la PRÓXIMA (hoy más tarde o mañana)
|
||||
for (const item of favsHoy) {
|
||||
const mInicio = (item.horario || '').match(/(\d{1,2}):(\d{2})/);
|
||||
if (mInicio) {
|
||||
const tInicio = `${mInicio[1].padStart(2, '0')}:${mInicio[2]}`;
|
||||
if (tInicio > horaActualStr) {
|
||||
const targetDate = new Date(ahora);
|
||||
targetDate.setHours(parseInt(mInicio[1], 10), parseInt(mInicio[2], 10), 0, 0);
|
||||
|
||||
const diff = targetDate.getTime() - ahora.getTime();
|
||||
if (!candidata || diff < candidata.diffMs) {
|
||||
candidata = { item, diffMs: diff, label: 'Próxima Clase' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si no encontramos hoy, buscamos mañana
|
||||
if (!candidata) {
|
||||
const mananaIdx = (hoyIdx + 1) % 7;
|
||||
const favsManana = this.horarios.filter(h => this.isFavorito(h.codigo) && this.DIAS_DOW[h.dia || ''] === mananaIdx);
|
||||
|
||||
for (const item of favsManana) {
|
||||
const mInicio = (item.horario || '').match(/(\d{1,2}):(\d{2})/);
|
||||
if (mInicio) {
|
||||
const targetDate = new Date(ahora);
|
||||
targetDate.setDate(ahora.getDate() + 1);
|
||||
targetDate.setHours(parseInt(mInicio[1], 10), parseInt(mInicio[2], 10), 0, 0);
|
||||
|
||||
const diff = targetDate.getTime() - ahora.getTime();
|
||||
if (!candidata || diff < candidata.diffMs) {
|
||||
candidata = { item, diffMs: diff, label: 'Próxima Clase' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.proximaClase = candidata;
|
||||
}
|
||||
|
||||
private reiniciarCountdown() {
|
||||
if (this.countdownTimer) {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.countdownTimer = null;
|
||||
}
|
||||
if (this.proximaClase) {
|
||||
this.countdownTimer = setInterval(() => {
|
||||
if (!this.proximaClase) { clearInterval(this.countdownTimer); return; }
|
||||
this.proximaClase.diffMs -= 60000; // Decrement by 1 minute
|
||||
if (this.proximaClase.diffMs < -5 * 60000) { // If 5 minutes past start
|
||||
// The class has started or passed, recalculate
|
||||
this.actualizarProximaClase();
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
}
|
||||
|
||||
formatCountdown(diffMs: number): string {
|
||||
const minutos = Math.round(diffMs / 60000);
|
||||
if (minutos <= 0) return 'Ahora mismo';
|
||||
if (minutos < 60) return `En ${minutos} min`;
|
||||
const horas = Math.floor(minutos / 60);
|
||||
const mins = minutos % 60;
|
||||
return mins > 0 ? `En ${horas}h ${mins}min` : `En ${horas}h`;
|
||||
}
|
||||
|
||||
countdownColor(diffMs: number): string {
|
||||
const minutos = Math.round(diffMs / 60000);
|
||||
if (minutos <= 30) return 'danger';
|
||||
if (minutos <= 120) return 'warning';
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determina si hay una coincidencia aproximada (Fuzzy)
|
||||
*/
|
||||
@@ -274,8 +418,8 @@ export class HomePage implements OnInit {
|
||||
|
||||
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?`
|
||||
message: isFav
|
||||
? `¿Deseas quitar la materia ${item.asignatura} de tus favoritos?`
|
||||
: `¿Deseas agregar la materia ${item.asignatura} a tus favoritos?`,
|
||||
buttons: [
|
||||
{
|
||||
@@ -300,13 +444,14 @@ export class HomePage implements OnInit {
|
||||
this.favoritos.push(item.codigo);
|
||||
}
|
||||
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
|
||||
await this.csvStorageService.saveFavoritesToCsv(this.favoritos);
|
||||
await this.csvStorageService.saveUserDataToCsv(this.favoritos, this.eventos);
|
||||
this.filterHorarios();
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
|
||||
await alert.present();
|
||||
}
|
||||
|
||||
@@ -351,7 +496,143 @@ export class HomePage implements OnInit {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: CredencialModalComponent
|
||||
});
|
||||
|
||||
|
||||
await modal.present();
|
||||
}
|
||||
|
||||
// --- Hitos Académicos (Agenda) ---
|
||||
|
||||
async openMateriaActions(item: ClaseHorario) {
|
||||
// Si estamos en cartelera general y NO es favorito, lo agregamos directamente
|
||||
if (this.viewMode === 'todas' && !this.isFavorito(item.codigo)) {
|
||||
this.toggleFavorito(item);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// En cualquier otro caso (estamos en favoritos o ya es favorito en la cartelera),
|
||||
// abrimos el menú de gestión (Action Sheet)
|
||||
const proximos = this.getEventosMateria(item.codigo);
|
||||
|
||||
|
||||
|
||||
const buttons: any[] = [
|
||||
{
|
||||
text: 'Agendar Parcial / TP / Final',
|
||||
icon: 'calendar-number-outline',
|
||||
handler: () => {
|
||||
this.abrirModalEvento(item);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Compartir Horario',
|
||||
icon: 'share-social-outline',
|
||||
handler: () => {
|
||||
this.shareItem(item);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if (proximos.length > 0) {
|
||||
buttons.unshift({
|
||||
text: 'Ver Agenda de la Materia',
|
||||
icon: 'list-outline',
|
||||
handler: () => {
|
||||
this.verAgendaMateria(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buttons.push({
|
||||
text: 'Quitar de Favoritos',
|
||||
role: 'destructive',
|
||||
icon: 'star-outline',
|
||||
handler: () => {
|
||||
this.toggleFavorito(item);
|
||||
}
|
||||
});
|
||||
|
||||
buttons.push({
|
||||
text: 'Cancelar',
|
||||
role: 'cancel',
|
||||
icon: 'close-outline'
|
||||
});
|
||||
|
||||
const actionSheet = await this.actionSheetCtrl.create({
|
||||
header: item.asignatura,
|
||||
subHeader: 'Gestión de materia',
|
||||
buttons: buttons
|
||||
});
|
||||
|
||||
await actionSheet.present();
|
||||
}
|
||||
|
||||
getEventosMateria(codigo: string): EventoAcademico[] {
|
||||
return this.eventos
|
||||
.filter(e => e.materiaCodigo === codigo)
|
||||
.sort((a, b) => new Date(a.fecha).getTime() - new Date(b.fecha).getTime());
|
||||
}
|
||||
|
||||
getProximoEvento(codigo: string): EventoAcademico | null {
|
||||
const ahora = new Date().getTime();
|
||||
const futuros = this.getEventosMateria(codigo)
|
||||
.filter(e => new Date(e.fecha).getTime() > ahora);
|
||||
|
||||
return futuros.length > 0 ? futuros[0] : null;
|
||||
}
|
||||
|
||||
|
||||
async abrirModalEvento(item: ClaseHorario, eventoExistente?: EventoAcademico) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: EventoModalComponent,
|
||||
componentProps: {
|
||||
materiaCodigo: item.codigo,
|
||||
materiaNombre: item.asignatura,
|
||||
eventoExistente: eventoExistente
|
||||
}
|
||||
});
|
||||
|
||||
await modal.present();
|
||||
|
||||
const { data, role } = await modal.onWillDismiss();
|
||||
if (role === 'saved' || role === 'deleted') {
|
||||
this.eventos = await this.eventosService.loadEventos();
|
||||
await this.csvStorageService.saveUserDataToCsv(this.favoritos, this.eventos);
|
||||
}
|
||||
}
|
||||
|
||||
async verAgendaMateria(item: ClaseHorario) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: AgendaModalComponent,
|
||||
componentProps: {
|
||||
materia: item
|
||||
}
|
||||
});
|
||||
|
||||
await modal.present();
|
||||
|
||||
await modal.onWillDismiss();
|
||||
|
||||
// Al cerrar la agenda, recargamos los eventos locales por si se agregó/editó alguno
|
||||
this.eventos = await this.eventosService.loadEventos();
|
||||
|
||||
// Y aseguramos que la próxima clase y los badges se actualicen
|
||||
this.actualizarProximaClase();
|
||||
|
||||
// Guardamos en CSV por si hubo cambios
|
||||
await this.csvStorageService.saveUserDataToCsv(this.favoritos, this.eventos);
|
||||
}
|
||||
|
||||
|
||||
|
||||
formatFechaEvento(fechaIso: string): string {
|
||||
const d = new Date(fechaIso);
|
||||
return d.toLocaleDateString([], { day: '2-digit', month: '2-digit' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
isEventoUrgente(fechaIso: string): boolean {
|
||||
const diff = new Date(fechaIso).getTime() - new Date().getTime();
|
||||
return diff > 0 && diff < (1000 * 60 * 60 * 48); // Menos de 48 horas
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
||||
import { FilePicker } from '@capawesome/capacitor-file-picker';
|
||||
import { EventoAcademico } from './data';
|
||||
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -23,7 +25,7 @@ export class CsvStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
async saveFavoritesToCsv(favoritos: string[]) {
|
||||
async saveUserDataToCsv(favoritos: string[], eventos: EventoAcademico[]) {
|
||||
try {
|
||||
const hasPermission = await this.ensurePermissions();
|
||||
if (!hasPermission) {
|
||||
@@ -31,12 +33,22 @@ export class CsvStorageService {
|
||||
return;
|
||||
}
|
||||
|
||||
let csvContent = 'tipo,valor\n';
|
||||
let csvContent = 'tipo,materia,id_o_valor,extra1,extra2,extra3,extra4\n';
|
||||
|
||||
// Filas de favoritos
|
||||
favoritos.forEach(codigo => {
|
||||
csvContent += `favorito,${codigo}\n`;
|
||||
csvContent += `favorito,${codigo},,,,\n`;
|
||||
});
|
||||
|
||||
// Intentamos borrar si existe para forzar la actualización transparente en Scoped Storage
|
||||
// Filas de eventos
|
||||
eventos.forEach(ev => {
|
||||
// Formato: evento,CODIGO,ID,TIPO,FECHA,AULA,LINK,NOTAS
|
||||
// Para simplificar el CSV sin escapar comas en notas, reemplazamos comas por punto y coma
|
||||
const notasEscaped = (ev.notas || '').replace(/,/g, ';');
|
||||
csvContent += `evento,${ev.materiaCodigo},${ev.id},${ev.tipo},${ev.fecha},${ev.aula || ''},${ev.link || ''},${notasEscaped}\n`;
|
||||
});
|
||||
|
||||
// Intentamos borrar si existe
|
||||
try {
|
||||
await Filesystem.deleteFile({
|
||||
path: this.FILE_NAME,
|
||||
@@ -50,13 +62,14 @@ export class CsvStorageService {
|
||||
directory: Directory.Documents,
|
||||
encoding: Encoding.UTF8
|
||||
});
|
||||
console.log('CSV guardado exitosamente en Documents');
|
||||
console.log('CSV con datos de usuario guardado en Documents');
|
||||
} catch (e) {
|
||||
console.error('Error al guardar el CSV:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async loadFavoritesFromCsv(): Promise<string[] | null> {
|
||||
|
||||
async loadUserDataFromCsv(): Promise<{ favoritos: string[], eventos: EventoAcademico[] } | null> {
|
||||
try {
|
||||
const hasPermission = await this.ensurePermissions();
|
||||
if (!hasPermission) return null;
|
||||
@@ -71,23 +84,42 @@ export class CsvStorageService {
|
||||
|
||||
const lines = contents.data.toString().split('\n');
|
||||
const favs: string[] = [];
|
||||
const evs: EventoAcademico[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('favorito,')) {
|
||||
const parts = line.split(',');
|
||||
if (!line) continue;
|
||||
|
||||
const parts = line.split(',');
|
||||
const tipo = parts[0];
|
||||
|
||||
if (tipo === 'favorito') {
|
||||
if (parts.length > 1 && parts[1]) {
|
||||
favs.push(parts[1]);
|
||||
}
|
||||
} else if (tipo === 'evento') {
|
||||
// evento,materia,id,tipo_ev,fecha,aula,link,notas
|
||||
if (parts.length >= 5) {
|
||||
evs.push({
|
||||
materiaCodigo: parts[1],
|
||||
id: parts[2],
|
||||
tipo: parts[3] as any,
|
||||
fecha: parts[4],
|
||||
aula: parts[5] || '',
|
||||
link: parts[6] || '',
|
||||
notas: (parts[7] || '').replace(/;/g, ',')
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return favs.length > 0 ? favs : null;
|
||||
return { favoritos: favs, eventos: evs };
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async importFavoritesFromFilePicker(): Promise<string[] | null> {
|
||||
try {
|
||||
const result = await FilePicker.pickFiles({
|
||||
@@ -105,18 +137,25 @@ export class CsvStorageService {
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('favorito,')) {
|
||||
const parts = line.split(',');
|
||||
if (!line) continue;
|
||||
|
||||
const parts = line.split(',');
|
||||
const tipo = parts[0];
|
||||
|
||||
if (tipo === 'favorito') {
|
||||
if (parts.length > 1 && parts[1]) {
|
||||
favs.push(parts[1]);
|
||||
}
|
||||
}
|
||||
// Nota: para el Picker manual solo recuperamos favoritos por ahora para simplicidad,
|
||||
// o podríamos reusar la lógica de loadUserDataFromCsv si el CSV es idéntico.
|
||||
}
|
||||
|
||||
if (favs.length > 0) {
|
||||
await this.saveFavoritesToCsv(favs);
|
||||
await this.saveUserDataToCsv(favs, []);
|
||||
return favs;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch(e) {
|
||||
console.warn('File picker cancelado o falló', e);
|
||||
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import { LocalNotifications, LocalNotificationSchema } from '@capacitor/local-notifications';
|
||||
|
||||
export interface ClaseHorario {
|
||||
codigo: string;
|
||||
@@ -15,6 +15,17 @@ export interface ClaseHorario {
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface EventoAcademico {
|
||||
id: string;
|
||||
materiaCodigo: string;
|
||||
tipo: 'Parcial' | 'TP' | 'Final' | 'Otro';
|
||||
fecha: string; // ISO string
|
||||
aula?: string;
|
||||
link?: string;
|
||||
notas?: string;
|
||||
}
|
||||
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
@@ -96,6 +107,84 @@ export class DataService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Programa notificaciones locales 30 minutos antes de cada clase favorita del día actual.
|
||||
* Usa IDs estables (hash del código + dia + horario) para evitar notificaciones duplicadas.
|
||||
*/
|
||||
public async scheduleClassReminders(favoriteItems: ClaseHorario[]) {
|
||||
try {
|
||||
const permStatus = await LocalNotifications.checkPermissions();
|
||||
if (permStatus.display !== 'granted') return;
|
||||
|
||||
const DIAS_ES: Record<string, number> = {
|
||||
'lunes': 1, 'martes': 2, 'miércoles': 3, 'miercoles': 3,
|
||||
'jueves': 4, 'viernes': 5, 'sábado': 6, 'sabado': 6, 'domingo': 0
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const todayDow = now.getDay(); // 0=Dom, 1=Lun...
|
||||
|
||||
const notificationsToSchedule: LocalNotificationSchema[] = [];
|
||||
const idsToCancel: number[] = [];
|
||||
|
||||
for (const item of favoriteItems) {
|
||||
if (!item.dia || !item.horario) continue;
|
||||
|
||||
const diaNorm = item.dia.trim().toLowerCase();
|
||||
const classDow = DIAS_ES[diaNorm];
|
||||
if (classDow === undefined || classDow !== todayDow) continue;
|
||||
|
||||
// Extraer primer horario de inicio, ej: "18:00 a 22:00" → "18:00"
|
||||
const timeMatch = item.horario.match(/(\d{1,2}):(\d{2})/);
|
||||
if (!timeMatch) continue;
|
||||
|
||||
const classHour = parseInt(timeMatch[1], 10);
|
||||
const classMin = parseInt(timeMatch[2], 10);
|
||||
|
||||
const classStart = new Date(now);
|
||||
classStart.setHours(classHour, classMin, 0, 0);
|
||||
|
||||
const reminderTime = new Date(classStart.getTime() - 30 * 60 * 1000);
|
||||
|
||||
// Solo programa si el recordatorio es en el futuro
|
||||
if (reminderTime <= now) continue;
|
||||
|
||||
// ID estable: hash simple basado en codigo+dia+horario
|
||||
const stableId = this.stableHash(`${item.codigo}-${item.dia}-${item.horario}`);
|
||||
|
||||
idsToCancel.push(stableId);
|
||||
notificationsToSchedule.push({
|
||||
id: stableId,
|
||||
title: `🎓 Clase en 30 minutos`,
|
||||
body: `${item.asignatura} — Aula ${item.aula} a las ${timeMatch[0]}`,
|
||||
schedule: { at: reminderTime },
|
||||
extra: { codigo: item.codigo }
|
||||
});
|
||||
}
|
||||
|
||||
if (idsToCancel.length > 0) {
|
||||
await LocalNotifications.cancel({ notifications: idsToCancel.map(id => ({ id })) }).catch(() => {});
|
||||
}
|
||||
|
||||
if (notificationsToSchedule.length > 0) {
|
||||
await LocalNotifications.schedule({ notifications: notificationsToSchedule });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error programando recordatorios de clase:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Genera un entero de 32 bits estable a partir de un string (djb2) */
|
||||
private stableHash(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ str.charCodeAt(i);
|
||||
hash = hash >>> 0; // Mantener como unsigned 32-bit
|
||||
}
|
||||
// LocalNotifications requiere IDs positivos y razonables
|
||||
return (hash % 2_000_000) + 100_000;
|
||||
}
|
||||
|
||||
private async checkRoomChanges(oldData: ClaseHorario[], newData: ClaseHorario[], favoritos: string[]) {
|
||||
for (const item of newData) {
|
||||
if (favoritos.includes(item.codigo)) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Preferences } from '@capacitor/preferences';
|
||||
import { LocalNotifications, LocalNotificationSchema } from '@capacitor/local-notifications';
|
||||
import { Haptics, ImpactStyle } from '@capacitor/haptics';
|
||||
import { EventoAcademico } from './data';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class EventosService {
|
||||
private readonly PREF_EVENTOS = 'eventos_academicos';
|
||||
private eventos: EventoAcademico[] = [];
|
||||
|
||||
constructor() {
|
||||
this.loadEventos();
|
||||
}
|
||||
|
||||
async loadEventos(): Promise<EventoAcademico[]> {
|
||||
const { value } = await Preferences.get({ key: this.PREF_EVENTOS });
|
||||
if (value) {
|
||||
this.eventos = JSON.parse(value);
|
||||
}
|
||||
return this.eventos;
|
||||
}
|
||||
|
||||
getEventosByMateria(codigo: string): EventoAcademico[] {
|
||||
return this.eventos.filter(e => e.materiaCodigo === codigo);
|
||||
}
|
||||
|
||||
getAllEventos(): EventoAcademico[] {
|
||||
return this.eventos;
|
||||
}
|
||||
|
||||
async saveEvento(evento: EventoAcademico) {
|
||||
const index = this.eventos.findIndex(e => e.id === evento.id);
|
||||
if (index > -1) {
|
||||
this.eventos[index] = evento;
|
||||
} else {
|
||||
this.eventos.push(evento);
|
||||
}
|
||||
await this.syncStorage();
|
||||
await this.scheduleNotifications(evento);
|
||||
await Haptics.impact({ style: ImpactStyle.Medium }).catch(() => {});
|
||||
}
|
||||
|
||||
async deleteEvento(id: string) {
|
||||
this.eventos = this.eventos.filter(e => e.id !== id);
|
||||
await this.syncStorage();
|
||||
await this.cancelNotifications(id);
|
||||
await Haptics.impact({ style: ImpactStyle.Light }).catch(() => {});
|
||||
}
|
||||
|
||||
private async syncStorage() {
|
||||
await Preferences.set({
|
||||
key: this.PREF_EVENTOS,
|
||||
value: JSON.stringify(this.eventos)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Programa notificaciones escalonadas para un hito académico: 7 días, 1 día y 2 horas antes.
|
||||
*/
|
||||
async scheduleNotifications(evento: EventoAcademico) {
|
||||
const now = new Date();
|
||||
const eventDate = new Date(evento.fecha);
|
||||
|
||||
if (eventDate <= now) return;
|
||||
|
||||
// Cancelamos notificaciones previas de este ID
|
||||
await this.cancelNotifications(evento.id);
|
||||
|
||||
const notifs: LocalNotificationSchema[] = [];
|
||||
const baseId = this.stringToNumericId(evento.id);
|
||||
|
||||
const milestones = [
|
||||
{ label: 'en 7 días', offset: 7 * 24 * 60 * 60 * 1000, suffix: '7d' },
|
||||
{ label: 'mañana', offset: 24 * 60 * 60 * 1000, suffix: '1d' },
|
||||
{ label: 'en 2 horas', offset: 2 * 60 * 60 * 1000, suffix: '2h' }
|
||||
];
|
||||
|
||||
milestones.forEach((m, i) => {
|
||||
const scheduleAt = new Date(eventDate.getTime() - m.offset);
|
||||
if (scheduleAt > now) {
|
||||
notifs.push({
|
||||
id: baseId + i,
|
||||
title: `📌 Recordatorio: ${evento.tipo}`,
|
||||
body: `${evento.materiaCodigo}: ${m.label} es el examen/entrega.`,
|
||||
schedule: { at: scheduleAt },
|
||||
extra: { eventoId: evento.id, codigo: evento.materiaCodigo }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (notifs.length > 0) {
|
||||
await LocalNotifications.schedule({ notifications: notifs });
|
||||
}
|
||||
}
|
||||
|
||||
async cancelNotifications(eventoId: string) {
|
||||
const baseId = this.stringToNumericId(eventoId);
|
||||
const ids = [baseId, baseId + 1, baseId + 2];
|
||||
await LocalNotifications.cancel({ notifications: ids.map(id => ({ id })) }).catch(() => {});
|
||||
}
|
||||
|
||||
private stringToNumericId(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash % 100000) + 200000;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user