feat(v1.2.1): Integración nativa de Google Calendar UI y exportación dual offline WebView/Share

This commit is contained in:
2026-03-27 16:37:42 -03:00
parent f1da6175ae
commit ca33ed19ce
67 changed files with 995 additions and 285 deletions
@@ -32,16 +32,32 @@
</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">Día del Hito</ion-label>
<ion-datetime-button datetime="date-input"></ion-datetime-button>
<ion-popover [keepContentsMounted]="true">
<ng-template>
<ion-datetime id="date-input" [(ngModel)]="fecha" presentation="date" prefer-wheel="true" [showDefaultButtons]="true" doneText="OK" cancelText="Atrás"></ion-datetime>
</ng-template>
</ion-popover>
</ion-item>
</ion-col>
<ion-col size="6">
<ion-item class="input-item side-item">
<ion-label position="stacked">Hora aprox.</ion-label>
<ion-datetime-button datetime="time-input"></ion-datetime-button>
<ion-popover [keepContentsMounted]="true">
<ng-template>
<ion-datetime id="time-input" [(ngModel)]="fecha" presentation="time" prefer-wheel="true" [showDefaultButtons]="true" doneText="OK" cancelText="Atrás"></ion-datetime>
</ng-template>
</ion-popover>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>
<ion-grid class="ion-no-padding">
<ion-row>
@@ -72,6 +88,11 @@
{{ isEdit ? 'Actualizar Agenda' : 'Agendar Hito' }}
</ion-button>
<ion-button expand="block" fill="outline" color="dark" (click)="exportarCalendario()" class="export-btn" style="margin-top: 10px;">
<ion-icon name="calendar-outline" slot="start"></ion-icon>
Añadir a Google Calendar
</ion-button>
<ion-button *ngIf="isEdit" expand="block" fill="clear" color="danger" (click)="eliminar()" class="delete-btn">
<ion-icon name="trash-outline" slot="start"></ion-icon>
Eliminar Evento
@@ -1,9 +1,10 @@
import { Component, Input, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { IonicModule, ModalController } from '@ionic/angular';
import { IonicModule, ModalController, AlertController, ToastController } from '@ionic/angular';
import { EventoAcademico } from '../../services/data';
import { EventosService } from '../../services/eventos.service';
import { CapacitorCalendar } from '@ebarooni/capacitor-calendar';
@Component({
@@ -28,7 +29,9 @@ export class EventoModalComponent implements OnInit {
constructor(
private modalCtrl: ModalController,
private eventosService: EventosService
private eventosService: EventosService,
private alertCtrl: AlertController,
private toastCtrl: ToastController
) { }
ngOnInit() {
@@ -63,8 +66,52 @@ export class EventoModalComponent implements OnInit {
async eliminar() {
if (this.eventoExistente) {
await this.eventosService.deleteEvento(this.eventoExistente.id);
this.modalCtrl.dismiss(null, 'deleted');
const alert = await this.alertCtrl.create({
header: 'Eliminar Evento',
message: '¿Estás seguro que querés eliminar este hito de tu agenda?',
buttons: [
{
text: 'Cancelar',
role: 'cancel'
},
{
text: 'Eliminar',
role: 'destructive',
handler: async () => {
await this.eventosService.deleteEvento(this.eventoExistente!.id);
this.modalCtrl.dismiss(null, 'deleted');
}
}
]
});
await alert.present();
}
}
async exportarCalendario() {
try {
const startDate = new Date(this.fecha);
const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // +2 horas por defecto
let descString = this.notas || '';
if (this.link) descString += `\n\nLink de cursada: ${this.link}`;
await CapacitorCalendar.createEventWithPrompt({
title: `${this.tipo} de ${this.materiaNombre}`,
description: descString,
location: this.aula ? `Aula ${this.aula}, Universidad de la Ciudad` : 'Universidad de la Ciudad',
startDate: startDate.getTime(),
endDate: endDate.getTime(),
isAllDay: false
});
} catch (e) {
console.error('Error abriendo enlace de Calendario:', e);
const toast = await this.toastCtrl.create({
message: 'No pudimos abrir tu Calendario. Verificá si tenés una app instalada.',
duration: 2500, color: 'warning'
});
toast.present();
}
}
}
+116 -33
View File
@@ -7,6 +7,9 @@
Cartelera de Aulas
</ion-title>
<ion-buttons slot="end">
<ion-button *ngIf="viewMode === 'favoritos'" (click)="importarRespaldoManual()" title="Importar Respaldo">
<ion-icon slot="icon-only" name="cloud-download-outline"></ion-icon>
</ion-button>
<ion-button (click)="isInfoModalOpen = true">
<ion-icon slot="icon-only" name="information-circle-outline"></ion-icon>
</ion-button>
@@ -19,6 +22,7 @@
<ion-segment [(ngModel)]="viewMode" (ionChange)="onViewModeChange()" color="primary">
<ion-segment-button value="favoritos">Favoritos</ion-segment-button>
<ion-segment-button value="todas">Cartelera</ion-segment-button>
<ion-segment-button value="plan">Mi Carrera</ion-segment-button>
</ion-segment>
</div>
</ion-toolbar>
@@ -43,40 +47,58 @@
</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>
<p><strong>Aulas UNICABA</strong> es el visor especializado, independiente y <strong>offline-first</strong> para
la cartelera de horarios, campus virtual y seguimiento de tu progreso académico de la Universidad de la Ciudad.
Desarrollado pensando en
el uso en movimiento, el ahorro de batería y la mala conectividad.</p>
<h3>Nuevas Características y Funciones</h3>
<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>
<li><strong>Cartelera Rápida y Offline:</strong> Consulta de materias organizada por días y cacheada al
instante. Muestra datos rápido aunque estés bajo tierra sin red.</li>
<li><strong>Mi Carrera y Trackeo:</strong> Ingresá a tu Plan de Estudios interactivo. Usá los Action Sheets
nativos para marcar materias como <strong>'Aprobadas'</strong> (Fucsia) o <strong>'En Curso'</strong>
(Violeta), y gestioná tus <strong>Materias Optativas</strong> desde el catálogo
directamente con un clic.</li>
<li><strong>Próxima Clase Inteligente:</strong> Un banner destacado superior en Favoritos te avisa cuándo es y
cuánto falta
para tu próxima clase, con un reloj regresivo dinámico en vivo.</li>
<li><strong>Alertas Automáticas:</strong> Si la universidad cambia repentinamente el número de aula o el link de
Meet/Zoom de tus materias, serás notificado. También recibís pre-alertas de 30 minutos antes de cursar.</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>
<h3>Agenda de Hitos Académicos</h3>
<ul>
<li>Accedé rápidamente para agendar <strong>Parciales / Trabajos Prácticos / Finales</strong> desde cualquier
materia para llevar un control estricto.</li>
<li>El robusto sistema de calendario nativo "Wheels" evitará borrados accidentales de tus fechas importantes.
</li>
</ul>
<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.
<h3>Credencial Inmediata y Exportación</h3>
<ul>
<li><strong>Credencial Digital:</strong> Tocando el logo superior izquierdo obtenés tu QR offline y se sube el
brillo de tu pantalla al máximo de forma automática para facilitar la lectura.</li>
<li><strong>Respaldo Portable Seguro:</strong> Apretando la "Nubecita" o "Disquete" podés emitir un archivo de
backup (JSON seguro) hacia la memoria o nube de tu teléfono, para que restaures de inmediato toda la
configuración si mudás a otro equipo.</li>
</ul>
<h3>Privacidad Zero-Trust</h3>
<p><strong>Sin Servidores Intermediarios:</strong> No dependemos de bases de datos centralizadas, ni recopilamos
credenciales. Cada sincronización viaja directo desde Aulas UNICABA hasta el servidor del SIU/Google localmente.
</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" style="margin-top: 30px;"><small>Tu ecosistema digital universitario, diseñado por y
para estudiantes.</small></p>
<p class="ion-text-center" style="margin-top: 30px;"><small><strong>Versión 1.2.1</strong></small></p>
<p class="ion-text-center"><small>Versión 1.1.9 - 26/03/2026</small></p>
</ion-content>
</ng-template>
</ion-modal>
<ion-content [fullscreen]="true" color="light">
<ion-content [fullscreen]="true" color="light" (swipe)="onSwipe($event)">
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingText="Desliza para actualizar" refreshingSpinner="crescent"
refreshingText="Actualizando..."></ion-refresher-content>
@@ -123,7 +145,7 @@
<p>{{ errorMsg }}</p>
</div>
<ion-list *ngIf="!loading && !errorMsg && horariosFiltrados.length > 0">
<ion-list *ngIf="!loading && !errorMsg && horariosFiltrados.length > 0 && viewMode !== 'plan'">
<ion-card *ngFor="let item of horariosFiltrados" [class.highlight-card]="item.codigo === highlightedCodigo"
class="subject-card">
@@ -174,21 +196,82 @@
</ion-card>
</ion-list>
<div class="empty-state" *ngIf="!loading && !errorMsg && horariosFiltrados.length === 0">
<div class="empty-state" *ngIf="!loading && !errorMsg && horariosFiltrados.length === 0 && viewMode !== 'plan'">
<p>No se encontraron horarios para la selección actual.</p>
</div>
<!-- Vista: Mi Carrera (Plan de Estudios) -->
<div *ngIf="viewMode === 'plan'" class="ion-padding content-plan">
<ion-card class="carrera-selector-card">
<ion-card-content>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span class="selector-title" style="margin: 0;">Plan de Estudios / Recorrido</span>
<ion-button fill="clear" size="small" (click)="forzarRespaldo()" style="margin: 0;">
<ion-icon name="save-outline" slot="icon-only" color="primary"></ion-icon>
</ion-button>
</div>
<p class="selector-desc">Selecciona tu carrera para guardar tu avance y ver el recorrido sugerido, toca materias
para marcarlas como aprobadas o en curso.</p>
<ion-item lines="none" class="carrera-item">
<ion-icon name="school-outline" slot="start" color="primary"></ion-icon>
<ion-select placeholder="Elegí una carrera" [(ngModel)]="carreraSeleccionada" interface="popover">
<ion-select-option value="tecnologias">Lic. en Tecnologías Digitales</ion-select-option>
<ion-select-option value="datos">Lic. en Ciencia de Datos</ion-select-option>
<ion-select-option value="videojuegos">Lic. en Des. de Videojuegos</ion-select-option>
</ion-select>
</ion-item>
</ion-card-content>
</ion-card>
<div *ngIf="carreraSeleccionada" class="tabla-recorrido-container fade-in">
<table class="recorrido-table">
<thead>
<tr>
<th>1° Cuat.</th>
<th>2° Cuat.</th>
<th>3° Cuat.</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let row of getRecorridoData()">
<td>
<div class="materia-cell" [class.aprobada]="isAprobada(row.c1.name)"
[class.encurso]="isEnCurso(row.c1.name)" [class.optativa-cell]="row.c1.isOptativa"
(click)="handleCellClick(row.c1)">
{{ row.c1.name }}
</div>
</td>
<td>
<div class="materia-cell" [class.aprobada]="isAprobada(row.c2.name)"
[class.encurso]="isEnCurso(row.c2.name)" [class.optativa-cell]="row.c2.isOptativa"
(click)="handleCellClick(row.c2)">
{{ row.c2.name }}
</div>
</td>
<td>
<div class="materia-cell" [class.aprobada]="isAprobada(row.c3.name)"
[class.encurso]="isEnCurso(row.c3.name)" [class.optativa-cell]="row.c3.isOptativa"
(click)="handleCellClick(row.c3)">
{{ row.c3.name }}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</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>
<ion-footer class="ion-no-border">
<ion-toolbar class="footer-toolbar">
<div class="modern-footer-btn" (click)="openMap()" title="Abrir en Mapas">
<ion-icon name="location" class="location-icon"></ion-icon>
<div class="footer-text-column">
<span class="footer-title">U de la Ciudad</span>
<span class="footer-address">Tte. Gral. J.D. Perón 802 • CABA</span>
</div>
<ion-icon name="navigate-circle" class="nav-icon"></ion-icon>
</div>
</ion-toolbar>
</ion-footer>
+178 -8
View File
@@ -47,17 +47,61 @@
}
// Footer replica styles
.footer-content {
.footer-toolbar {
--background: var(--ion-background-color);
--border-width: 0;
padding: 8px 14px;
padding-bottom: calc(8px + var(--ion-safe-area-bottom, 0px));
}
.modern-footer-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
background: linear-gradient(135deg, var(--ion-color-primary), var(--ion-color-secondary));
border-radius: 16px;
padding: 12px 16px;
color: white;
box-shadow: 0 4px 15px rgba(var(--ion-color-primary-rgb), 0.3);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
.footer-text {
color: white;
font-size: 13px;
line-height: 1.2;
text-align: center;
&:active {
transform: scale(0.97);
box-shadow: 0 2px 8px rgba(var(--ion-color-primary-rgb), 0.2);
}
.location-icon {
font-size: 24px;
margin-right: 12px;
background: rgba(255, 255, 255, 0.2);
padding: 8px;
border-radius: 12px;
flex-shrink: 0;
}
.footer-text-column {
flex: 1;
display: flex;
flex-direction: column;
.footer-title {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.footer-address {
font-size: 12px;
opacity: 0.9;
}
}
.nav-icon {
font-size: 28px;
opacity: 0.95;
margin-left: 10px;
flex-shrink: 0;
}
}
@@ -344,4 +388,130 @@ ion-card.subject-card {
ion-icon { font-size: 1rem; flex-shrink: 0; }
}
}
// ─── Recorrido Académico (Plan de Estudios) ───────────────────────────
/* --- Vista: Mi Carrera (Plan de Estudios) --- */
.content-plan {
background: var(--ion-color-light);
}
.carrera-selector-card {
margin: 10px 0 20px 0;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
ion-card-content {
padding: 16px;
}
.selector-title {
display: block;
font-size: 1.15rem;
font-weight: 700;
color: var(--ion-color-dark);
margin-bottom: 6px;
}
.selector-desc {
font-size: 0.88rem;
color: var(--ion-color-medium);
margin-bottom: 16px;
line-height: 1.4;
}
.carrera-item {
--background: var(--ion-color-light);
border-radius: 8px;
--padding-start: 10px;
--inner-padding-end: 10px;
ion-select {
font-weight: 600;
color: var(--ion-color-primary);
width: 100%;
}
}
}
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.recorrido-table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
th {
background: var(--ion-color-primary);
color: white;
padding: 8px 4px;
text-align: center;
font-weight: 600;
border-radius: 4px 4px 0 0;
}
td {
padding: 4px;
vertical-align: top;
width: 33.33%;
}
.materia-cell {
padding: 8px 4px;
height: 100%;
min-height: 60px;
background: var(--ion-color-light);
border-radius: 6px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid transparent;
&:active {
transform: scale(0.95);
}
&.aprobada {
background: #682cad;
color: white;
font-weight: 600;
box-shadow: 0 2px 6px rgba(104, 44, 173, 0.3);
border-color: rgba(0,0,0,0.1);
}
&.encurso {
background: rgba(var(--ion-color-secondary-rgb), 0.15); // Subtono Cyan/Celeste
color: var(--ion-color-secondary);
font-weight: 600;
border: 1.5px solid var(--ion-color-secondary);
}
&.optativa-cell:not(.aprobada):not(.encurso) {
border: 1.5px dashed var(--ion-color-medium);
color: var(--ion-color-primary);
background: transparent;
font-weight: 600;
}
}
}
.instruccion-marcar {
text-align: center;
color: var(--ion-color-medium);
margin-top: 12px;
font-style: italic;
}␍
+358 -14
View File
@@ -9,7 +9,7 @@ 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';
import { LocalStorageService } from '../services/local-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';
@@ -33,7 +33,7 @@ export class HomePage implements OnInit {
horariosFiltrados: ClaseHorario[] = [];
favoritos: string[] = [];
viewMode: 'favoritos' | 'todas' = 'todas';
viewMode: 'favoritos' | 'todas' | 'plan' = 'todas';
searchText: string = '';
highlightedCodigo: string | null = null;
@@ -46,6 +46,25 @@ export class HomePage implements OnInit {
private countdownTimer: any = null;
eventos: EventoAcademico[] = [];
aprobadas: string[] = [];
encurso: string[] = [];
optativasSeleccionadas: { slot: string, materia: string }[] = [];
opcionesOptativas: string[] = [
'Desarrollo en Dispositivos Móviles (ASIG00130)',
'Diseño y Producción de Videojuegos (ASIG00133)',
'Herramientas Jurídicas en la Industria Digital (ASIG00132)',
'IOT - Internet de las cosas (ASIG00189)',
'Análisis de datos II: Sistemas expertos y redes de conocimiento (ASIG00131)',
'Gestión del Talento Humano en la Industria digital (ASIG00205)',
'Business Intelligence (ASIG00201)',
'Bases de Datos (ASIG00199)',
'Marketing Digital (ASIG00215)',
'Gestión de Redes Informáticas (ASIG00296)',
'Transformación Digital en las Organizaciones (ASIG00297)'
];
carreraSeleccionada: string = 'tecnologias';
// Mapa de días en español al getDay() de JS (0=Dom)
@@ -60,7 +79,7 @@ export class HomePage implements OnInit {
private route: ActivatedRoute,
private modalCtrl: ModalController,
private alertCtrl: AlertController,
private csvStorageService: CsvStorageService,
private localStorageService: LocalStorageService,
private actionSheetCtrl: ActionSheetController,
private eventosService: EventosService
) { }
@@ -92,10 +111,13 @@ export class HomePage implements OnInit {
this.loading = true;
// 0. Intentar cargar los datos de usuario desde el archivo CSV seguro
const userData = await this.csvStorageService.loadUserDataFromCsv();
// 0. Intentar cargar los datos de usuario desde Preferences (incluye parche de migración)
const userData = await this.localStorageService.loadData();
if (userData) {
this.favoritos = userData.favoritos;
this.aprobadas = userData.aprobadas || [];
this.encurso = userData.encurso || [];
this.optativasSeleccionadas = userData.optativas || [];
// Sincronizar eventos cargados desde el CSV con el servicio de eventos
for (const ev of userData.eventos) {
await this.eventosService.saveEvento(ev);
@@ -151,8 +173,8 @@ export class HomePage implements OnInit {
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);
// Auto-reparación/Actualización del Respaldo
this.guardarProgreso();
if (event) event.target.complete();
},
@@ -191,9 +213,19 @@ export class HomePage implements OnInit {
{
text: 'Buscar Respaldo',
handler: async () => {
const imported = await this.csvStorageService.importFavoritesFromFilePicker();
if (imported && imported.length > 0) {
this.favoritos = imported;
const imported = await this.localStorageService.importBackup();
if (imported && (imported.favoritos.length > 0 || imported.eventos.length > 0 || imported.aprobadas?.length > 0 || imported.optativas?.length > 0 || imported.encurso?.length > 0)) {
this.favoritos = imported.favoritos;
this.aprobadas = imported.aprobadas || [];
this.encurso = imported.encurso || [];
this.optativasSeleccionadas = imported.optativas || [];
// Sincronizar eventos cargados desde el CSV con el servicio de eventos
for (const ev of imported.eventos) {
await this.eventosService.saveEvento(ev);
}
this.eventos = await this.eventosService.loadEventos();
this.viewMode = 'favoritos';
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
this.filterHorarios();
@@ -444,7 +476,7 @@ export class HomePage implements OnInit {
this.favoritos.push(item.codigo);
}
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
await this.csvStorageService.saveUserDataToCsv(this.favoritos, this.eventos);
this.guardarProgreso();
this.filterHorarios();
}
}
@@ -492,6 +524,29 @@ export class HomePage implements OnInit {
window.open(url, '_blank', 'noopener');
}
openMap() {
const address = 'Tte. Gral. Juan Domingo Perón 802, C1063, Cdad. Autónoma de Buenos Aires';
const encodedAddress = encodeURIComponent(address);
// Si estamos en un dispositivo móvil, preferimos los esquemas nativos para invocar al OS
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const isAndroid = /android/i.test(navigator.userAgent);
let url = '';
if (isAndroid) {
// En Android, geo:0,0?q= fuerza al sistema a abrir el selector de mapas (Ej: Waze, Maps)
url = `geo:0,0?q=${encodedAddress}`;
} else if (isIOS) {
// En iOS, maps:// invoca Apple Maps (en algunos casos permite elegir)
url = `maps://?q=${encodedAddress}`;
} else {
// Fallback para Web/Desktop
url = `https://www.google.com/maps/search/?api=1&query=${encodedAddress}`;
}
window.open(url, '_system');
}
async openCredencial() {
const modal = await this.modalCtrl.create({
component: CredencialModalComponent
@@ -597,7 +652,7 @@ export class HomePage implements OnInit {
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);
this.guardarProgreso();
}
}
@@ -619,8 +674,8 @@ export class HomePage implements OnInit {
// 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);
// Guardamos progreso por si hubo cambios
this.guardarProgreso();
}
@@ -634,5 +689,294 @@ export class HomePage implements OnInit {
const diff = new Date(fechaIso).getTime() - new Date().getTime();
return diff > 0 && diff < (1000 * 60 * 60 * 48); // Menos de 48 horas
}
// --- Recorrido Académico ---
getRecorridoData() {
const getSlot = (slotId: string, label: string) => {
const selected = this.optativasSeleccionadas.find(o => o.slot === slotId);
return { id: slotId, name: selected ? selected.materia : label, isOptativa: true };
};
const mat = (name: string) => ({ id: null, name: name, isOptativa: false });
if (this.carreraSeleccionada === 'tecnologias') {
return [
{ c1: mat('Gestión de Sistemas de Información'), c2: mat('Administración de Negocios Digitales'), c3: mat('Seguridad de la Información') },
{ c1: mat('Análisis de Datos I'), c2: mat('Sistemas Digitales'), c3: mat('Programación de Vanguardia') },
{ c1: mat('Taller: Proyectos Digitales'), c2: mat('Taller: Emprendedurismo en Innovación Digital'), c3: mat('Taller: Control de calidad de Software') },
{ c1: getSlot('tec_opt1', 'Optativa 1'), c2: getSlot('tec_opt2', 'Optativa 2'), c3: getSlot('tec_opt3', 'Optativa 3') }
];
} else if (this.carreraSeleccionada === 'datos') {
return [
{ c1: mat('Materia Obligatoria 1'), c2: mat('Materia Obligatoria 4'), c3: mat('Materia Obligatoria 7') },
{ c1: mat('Materia Obligatoria 2'), c2: mat('Materia Obligatoria 5'), c3: mat('Materia Obligatoria 8') },
{ c1: mat('Materia Obligatoria 3'), c2: mat('Materia Obligatoria 6'), c3: mat('Materia Obligatoria 9') },
{ c1: getSlot('dat_opt1', 'Optativa 1'), c2: getSlot('dat_opt2', 'Optativa 2'), c3: getSlot('dat_opt3', 'Optativa 3') }
];
} else {
return [
{ c1: mat('Materia Obligatoria 1'), c2: mat('Materia Obligatoria 4'), c3: mat('Materia Obligatoria 7') },
{ c1: mat('Materia Obligatoria 2'), c2: mat('Materia Obligatoria 5'), c3: mat('Materia Obligatoria 8') },
{ c1: mat('Materia Obligatoria 3'), c2: mat('Materia Obligatoria 6'), c3: mat('Materia Obligatoria 9') },
{ c1: getSlot('vid_opt1', 'Optativa 1'), c2: getSlot('vid_opt2', 'Optativa 2'), c3: getSlot('vid_opt3', 'Optativa 3') }
];
}
}
// --- Manejo de Swipe para Navegación ---
onSwipe(event: any) {
const modes: ('favoritos' | 'todas' | 'plan')[] = ['favoritos', 'todas', 'plan'];
const currentIndex = modes.indexOf(this.viewMode);
if (event.direction === 2) {
// Swipe Izquierda (Siguiente Tab)
if (currentIndex < modes.length - 1) {
this.viewMode = modes[currentIndex + 1];
this.onViewModeChange();
}
} else if (event.direction === 4) {
// Swipe Derecha (Tab Anterior)
if (currentIndex > 0) {
this.viewMode = modes[currentIndex - 1];
this.onViewModeChange();
}
}
}
isAprobada(materia: string): boolean {
return this.aprobadas.includes(materia);
}
isEnCurso(materia: string): boolean {
return this.encurso.includes(materia);
}
async handleCellClick(cell: { id: string | null, name: string, isOptativa: boolean }) {
if (cell.isOptativa && !this.optativasSeleccionadas.find(o => o.slot === cell.id)) {
await this.abrirSelectorOptativas(cell.id!);
return;
}
const isAppr = this.isAprobada(cell.name);
const isCour = this.isEnCurso(cell.name);
const buttons: any[] = [
{
text: isAppr ? 'Quitar Aprobado' : 'Marcar como Aprobada',
icon: isAppr ? 'close-circle-outline' : 'checkmark-done-circle-outline',
handler: () => {
this.setEstadoMateria(cell.name, 'aprobada');
}
},
{
text: isCour ? 'Quitar En Curso' : 'Marcar En Curso',
icon: isCour ? 'close-circle-outline' : 'book-outline',
handler: () => {
this.setEstadoMateria(cell.name, 'encurso');
}
}
];
if (cell.isOptativa) {
buttons.push({
text: 'Cambiar de Materia Optativa',
icon: 'swap-horizontal-outline',
handler: () => {
this.abrirSelectorOptativas(cell.id!);
}
});
buttons.push({
text: 'Borrar Selección de Optativa',
role: 'destructive',
icon: 'trash-outline',
handler: () => {
this.aprobadas = this.aprobadas.filter(m => m !== cell.name);
this.encurso = this.encurso.filter(m => m !== cell.name);
this.optativasSeleccionadas = this.optativasSeleccionadas.filter(o => o.slot !== cell.id);
this.guardarProgreso();
}
});
} else {
buttons.push({
text: 'Reiniciar Estado (Pendiente)',
role: 'destructive',
icon: 'refresh-outline',
handler: () => {
this.aprobadas = this.aprobadas.filter(m => m !== cell.name);
this.encurso = this.encurso.filter(m => m !== cell.name);
this.guardarProgreso();
}
});
}
buttons.push({
text: 'Cancelar',
icon: 'close',
role: 'cancel'
});
const actionSheet = await this.actionSheetCtrl.create({
header: cell.name,
subHeader: cell.isOptativa ? 'Gestión de Materia Optativa' : 'Gestión de Materia',
buttons: buttons
});
await actionSheet.present();
}
async abrirSelectorOptativas(slotId: string) {
const radios = this.opcionesOptativas.map(opt => ({
name: 'optativa',
type: 'radio' as any,
label: opt,
value: opt,
checked: false
}));
const alert = await this.alertCtrl.create({
header: 'Elegir Materia Optativa',
inputs: radios,
buttons: [
{ text: 'Cancelar', role: 'cancel' },
{
text: 'Seleccionar',
handler: (selectedValue) => {
if (selectedValue) {
const existingIndex = this.optativasSeleccionadas.findIndex(o => o.slot === slotId);
if (existingIndex > -1) {
const oldName = this.optativasSeleccionadas[existingIndex].materia;
this.aprobadas = this.aprobadas.filter(m => m !== oldName);
this.encurso = this.encurso.filter(m => m !== oldName);
this.optativasSeleccionadas[existingIndex].materia = selectedValue;
} else {
this.optativasSeleccionadas.push({ slot: slotId, materia: selectedValue });
}
this.guardarProgreso();
}
}
}
]
});
await alert.present();
}
setEstadoMateria(materia: string, estado: 'aprobada' | 'encurso') {
if (estado === 'aprobada') {
if (this.isAprobada(materia)) {
this.aprobadas = this.aprobadas.filter(m => m !== materia);
} else {
this.aprobadas.push(materia);
this.encurso = this.encurso.filter(m => m !== materia); // Quita de en curso si s aprueba
}
} else if (estado === 'encurso') {
if (this.isEnCurso(materia)) {
this.encurso = this.encurso.filter(m => m !== materia);
} else {
this.encurso.push(materia);
this.aprobadas = this.aprobadas.filter(m => m !== materia); // Quita aprobada si se pone en curso
}
}
this.guardarProgreso();
}
guardarProgreso() {
this.localStorageService.saveData({
favoritos: this.favoritos,
eventos: this.eventos,
aprobadas: this.aprobadas,
optativas: this.optativasSeleccionadas,
encurso: this.encurso
});
}
async forzarRespaldo() {
this.guardarProgreso();
const data = {
favoritos: this.favoritos,
eventos: this.eventos,
aprobadas: this.aprobadas,
optativas: this.optativasSeleccionadas,
encurso: this.encurso
};
const actionSheet = await this.actionSheetCtrl.create({
header: 'Guardar Respaldo',
subHeader: '¿A dónde querés exportar tus materias guardadas?',
buttons: [
{
text: 'Compartir (Drive, WhatsApp, Mail...)',
icon: 'share-social-outline',
handler: async () => {
await this.ejecutarExportacion(data, false);
}
},
{
text: 'Descargar Offline (A la carpeta Descargas)',
icon: 'download-outline',
handler: async () => {
await this.ejecutarExportacion(data, true);
}
},
{
text: 'Cancelar',
icon: 'close',
role: 'cancel'
}
]
});
await actionSheet.present();
}
private async ejecutarExportacion(data: any, localMode: boolean) {
const ok = await this.localStorageService.exportBackup(data, localMode);
if (ok) {
const toast = await this.toastCtrl.create({
message: localMode ? 'Archivo descargado en tu teléfono.' : 'Respaldo emitido correctamente.',
duration: 3500,
color: 'success',
icon: 'save'
});
toast.present();
await Haptics.impact({ style: ImpactStyle.Heavy }).catch(() => {});
}
}
async importarRespaldoManual() {
const imported = await this.localStorageService.importBackup();
if (imported && (imported.favoritos.length > 0 || imported.eventos.length > 0 || imported.aprobadas?.length > 0 || imported.optativas?.length > 0 || imported.encurso?.length > 0)) {
this.favoritos = imported.favoritos;
this.aprobadas = imported.aprobadas || [];
this.encurso = imported.encurso || [];
this.optativasSeleccionadas = imported.optativas || [];
for (const ev of imported.eventos) {
await this.eventosService.saveEvento(ev);
}
this.eventos = await this.eventosService.loadEventos();
await Preferences.set({ key: 'favoritos', value: JSON.stringify(this.favoritos) });
this.guardarProgreso();
this.filterHorarios();
const toast = await this.toastCtrl.create({
message: '¡Respaldo importado correctamente!',
duration: 3000,
color: 'success',
icon: 'checkmark-circle'
});
toast.present();
await Haptics.impact({ style: ImpactStyle.Heavy }).catch(() => {});
} else {
const toast = await this.toastCtrl.create({
message: 'No se procesó ningún archivo válido.',
duration: 2500,
color: 'warning',
icon: 'alert-circle'
});
toast.present();
}
}
}
-165
View File
@@ -1,165 +0,0 @@
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'
})
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 saveUserDataToCsv(favoritos: string[], eventos: EventoAcademico[]) {
try {
const hasPermission = await this.ensurePermissions();
if (!hasPermission) {
console.warn('Permisos denegados para escribir en Documents');
return;
}
let csvContent = 'tipo,materia,id_o_valor,extra1,extra2,extra3,extra4\n';
// Filas de favoritos
favoritos.forEach(codigo => {
csvContent += `favorito,${codigo},,,,\n`;
});
// 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,
directory: Directory.Documents
});
} catch (e) {}
await Filesystem.writeFile({
path: this.FILE_NAME,
data: csvContent,
directory: Directory.Documents,
encoding: Encoding.UTF8
});
console.log('CSV con datos de usuario guardado en Documents');
} catch (e) {
console.error('Error al guardar el CSV:', e);
}
}
async loadUserDataFromCsv(): Promise<{ favoritos: string[], eventos: EventoAcademico[] } | 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[] = [];
const evs: EventoAcademico[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
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 { favoritos: favs, eventos: evs };
} 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) 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.saveUserDataToCsv(favs, []);
return favs;
}
return null;
} catch(e) {
console.warn('File picker cancelado o falló', e);
return null;
}
}
}
+223
View File
@@ -0,0 +1,223 @@
import { Injectable } from '@angular/core';
import { Preferences } from '@capacitor/preferences';
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
import { FilePicker } from '@capawesome/capacitor-file-picker';
import { Share } from '@capacitor/share';
import { Capacitor } from '@capacitor/core';
import { EventoAcademico } from './data';
export interface AppData {
favoritos: string[];
eventos: EventoAcademico[];
aprobadas: string[];
optativas: {slot: string, materia: string}[];
encurso: string[];
}
@Injectable({
providedIn: 'root'
})
export class LocalStorageService {
private readonly PREF_KEY = 'aulas_unicaba_data';
private readonly BACKUP_PREFIX = 'Aulas_UNICABA_Respaldo_';
constructor() {}
// --- INTERNAL PREFERENCES (ROCK SOLID OFFLINE) ---
async saveData(data: AppData): Promise<void> {
try {
await Preferences.set({
key: this.PREF_KEY,
value: JSON.stringify(data)
});
} catch (e) {
console.error('Error saving to Preferences', e);
}
}
async loadData(): Promise<AppData | null> {
try {
let { value } = await Preferences.get({ key: this.PREF_KEY });
if (!value) {
// Intento de migración la primera vez si el usuario viene de versiones viejas con CSV
await this.migrateLegacyCsvIfNeeded();
const res = await Preferences.get({ key: this.PREF_KEY });
value = res.value;
}
if (value) {
const parsed = JSON.parse(value);
return {
favoritos: parsed.favoritos || [],
eventos: parsed.eventos || [],
aprobadas: parsed.aprobadas || [],
optativas: parsed.optativas || [],
encurso: parsed.encurso || []
};
}
return null;
} catch (e) {
console.error('Error loading from Preferences', e);
return null;
}
}
async exportBackup(data: AppData, localDownload: boolean = false): Promise<boolean> {
try {
const backupData = JSON.stringify(data, null, 2);
const safeDate = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16);
const fileName = `${this.BACKUP_PREFIX}${safeDate}.json`;
const platform = Capacitor.getPlatform();
if (localDownload && (platform === 'android' || platform === 'web')) {
// En Android y Web, forzamos la descarga extrayéndolo físicamente a Descargas
const blob = new Blob([backupData], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else {
// iOS: El Share sheet sí incluye la opción "Guardar en Archivos" por defecto
const writeResult = await Filesystem.writeFile({
path: fileName,
data: backupData,
directory: Directory.Cache,
encoding: Encoding.UTF8
});
await Share.share({
title: 'Respaldo Aulas UNICABA',
text: 'Este archivo contiene el respaldo de tus materias.',
url: writeResult.uri,
dialogTitle: 'Guardar Respaldo en Archivos'
});
}
return true;
} catch (e) {
console.error('Error exporting backup', e);
return false;
}
}
async importBackup(): Promise<AppData | null> {
try {
// 1. Let the user explicitly pick the file from their OS sin restriccion
const result = await FilePicker.pickFiles({
readData: true
});
const file = result.files[0];
if (!file || !file.data) return null;
// 2. Decode the Base64 data from the picker
const text = decodeURIComponent(escape(window.atob(file.data)));
// Intentamos cargar JSON puro primero
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object') {
const importedData: AppData = {
favoritos: parsed.favoritos || [],
eventos: parsed.eventos || [],
aprobadas: parsed.aprobadas || [],
optativas: parsed.optativas || [],
encurso: parsed.encurso || []
};
await this.saveData(importedData);
return importedData;
}
} catch (jsonErr) {
// Fallback: intentar parsear el legado .csv
const legacyParsed = await this.parseLegacyCsv(text, true);
return legacyParsed;
}
// Retornar en caso de que sea un json malformado que no falle el JSON.parse
return null;
} catch(e) {
console.warn('File picker cancelado o error al importar', e);
return null;
}
}
// --- MIGRATION LOGIC ---
private async migrateLegacyCsvIfNeeded(): Promise<void> {
try {
const contents = await Filesystem.readFile({
path: 'aulas_unicaba_datos.csv',
directory: Directory.Documents,
encoding: Encoding.UTF8
});
if (contents && contents.data) {
const legacyData = await this.parseLegacyCsv(contents.data.toString(), true);
if (legacyData) {
console.log('Migración Exitosa. Datos viejos capturados en el nuevo Preference API.');
}
}
} catch (e) {
// Ignore, user is fresh
}
}
private async parseLegacyCsv(text: string, saveAfter: boolean): Promise<AppData | null> {
try {
const lines = text.split('\n');
const favs: string[] = [];
const evs: EventoAcademico[] = [];
const aprobadas: string[] = [];
const encurso: string[] = [];
const optativasMap: {slot: string, materia: string}[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const parts = line.split(',');
if (parts.length < 2) continue;
const tipo = parts[0];
if (tipo === 'favorito') {
favs.push(parts[1]);
} else if (tipo === 'aprobada') {
aprobadas.push(parts[1]);
} else if (tipo === 'en_curso') {
encurso.push(parts[1]);
} else if (tipo === 'optativa' && parts[2]) {
optativasMap.push({ slot: parts[1], materia: parts[2] });
} else if (tipo === 'evento' && parts.length >= 5) {
evs.push({
materiaCodigo: parts[1],
id: parts[2],
tipo: parts[3] as any,
fecha: parts[4],
aula: parts[5] || '',
link: parts[6] || '',
notas: (parts[7] || '').replace(/;/g, ',')
});
}
}
const importedData: AppData = {
favoritos: favs, eventos: evs, aprobadas: aprobadas, optativas: optativasMap, encurso: encurso
};
if (saveAfter) {
await this.saveData(importedData);
}
return importedData;
} catch (e) {
console.error('Error parsing legacy CSV', e);
return null;
}
}
}