feat(v1.2.1): Integración nativa de Google Calendar UI y exportación dual offline WebView/Share
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user