feat(v1.2.1): Integración nativa de Google Calendar UI y exportación dual offline WebView/Share
@@ -34,6 +34,15 @@ Un visor híbrido especializado y offline-first desarrollado para la visualizaci
|
||||
- Usa IDs de notificación estables para evitar duplicados al sincronizar múltiples veces.
|
||||
- Funciona completamente **offline** — no requiere servidor externo.
|
||||
|
||||
### 📅 Agenda de Hitos y Exámenes *(Nuevo en v1.1.8)*
|
||||
- Añade exámenes (Parciales, Finales) y Trabajos Prácticos directamente desde el menú de una materia.
|
||||
- Los hitos muestran insignias de tiempo (rojo cuando falta poco, secundario el resto del tiempo) en la tarjeta de la materia en Cartelera.
|
||||
- La selección de fecha se hace mediante un popover ligero e intuitivo.
|
||||
|
||||
### 📍 Integración Nativa con Mapas *(Mejora v1.1.9)*
|
||||
- El pie de página renovado con un rediseño UI tipo "botón flotante".
|
||||
- Utiliza esquemas nativos (`geo:0,0?q=` en Android y `maps://` en iOS) que dejan al usuario **elegir la app de navegación preferida** (Waze, Google Maps, Apple Maps) al tocar la sede.
|
||||
|
||||
### 🪪 Credencial Digital Universitaria
|
||||
- Inicio de sesión directo al Campus Virtual (Moodle) con DNI y clave.
|
||||
- **Sin intermediarios**: las credenciales nunca pasan por ningún servidor tercero. El flujo HTTP se delega al motor nativo del SO mediante `CapacitorHttp`, evitando bloqueos CORS.
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "ar.edu.unicaba.cartelera"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 12
|
||||
versionName "1.1.9"
|
||||
versionCode 14
|
||||
versionName "1.2.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -21,6 +21,7 @@ dependencies {
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capawesome-capacitor-file-picker')
|
||||
implementation project(':ebarooni-capacitor-calendar')
|
||||
implementation project(':transistorsoft-capacitor-background-fetch')
|
||||
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 902 B After Width: | Height: | Size: 839 B |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -38,5 +38,8 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
|
||||
include ':capawesome-capacitor-file-picker'
|
||||
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')
|
||||
|
||||
include ':ebarooni-capacitor-calendar'
|
||||
project(':ebarooni-capacitor-calendar').projectDir = new File('../node_modules/@ebarooni/capacitor-calendar/android')
|
||||
|
||||
include ':transistorsoft-capacitor-background-fetch'
|
||||
project(':transistorsoft-capacitor-background-fetch').projectDir = new File('../node_modules/@transistorsoft/capacitor-background-fetch/android')
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "8kb"
|
||||
"maximumWarning": "10kb",
|
||||
"maximumError": "12kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
|
||||
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 390 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 37 KiB |
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aulas-unicaba",
|
||||
"version": "1.1.5",
|
||||
"version": "1.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aulas-unicaba",
|
||||
"version": "1.1.5",
|
||||
"version": "1.2.1",
|
||||
"dependencies": {
|
||||
"@angular/animations": "~20.3.18",
|
||||
"@angular/common": "~20.3.18",
|
||||
@@ -30,6 +30,7 @@
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "8.0.1",
|
||||
"@capawesome/capacitor-file-picker": "^8.0.2",
|
||||
"@ebarooni/capacitor-calendar": "^8.0.1",
|
||||
"@ionic/angular": "^8.0.0",
|
||||
"@transistorsoft/capacitor-background-fetch": "^8.0.0",
|
||||
"ionicons": "^7.0.0",
|
||||
@@ -600,7 +601,6 @@
|
||||
"integrity": "sha512-CVskZnF38IIxVVlKWi1VCz7YH/gHMJu2IY9bD1AVoBBGIe0xA4FRXJkW2Y+EDs9vQqZTkZZljhK5gL65Ro1PeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@angular-eslint/bundled-angular-compiler": "20.7.0",
|
||||
"eslint-scope": "^9.0.0"
|
||||
@@ -630,7 +630,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.18.tgz",
|
||||
"integrity": "sha512-XFxgSyjfs0SRD2vQVFJljmM4z9nTvUoI8TRqSre/+l8D2FgzD5pG67Aj2BgDgpSFAUkIcI37G48ijK7a3ZZ3WA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -780,7 +779,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.18.tgz",
|
||||
"integrity": "sha512-M62oQbSTRmnGavIVCwimoadg/PDWadgNhactMm9fgH0eM9rx+iWBAYJk4VufO0bwOhysFpRZpJgXlFjOifz/Jw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -797,7 +795,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.18.tgz",
|
||||
"integrity": "sha512-AaP/LCiDNcYmF135EEozjyR04NRBT38ZfBHQwjhgwiBBTejmvcpHwJaHSkraLpZqZzE4BQqqmgiQ1EJqxEwLVA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -811,7 +808,6 @@
|
||||
"integrity": "sha512-zsoEgLgnblmRbi47YwMghKirJ8IBKJ3+I8TxLBRIBrhx+KHFp+6oeDeLyu9H+djdyk88zexVd09wzR/YK73F0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.28.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@@ -844,7 +840,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.18.tgz",
|
||||
"integrity": "sha512-B+NQQngd/aDbcfW0zGLis3wTLDeHTeTYMl/mGKQH+HwdPaRCKI1wEtaXaOYVJXkP2FeThocPevB8gLwNlPQUUw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -870,7 +865,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.18.tgz",
|
||||
"integrity": "sha512-x6/99LfxolyZIFUL3Wr0OrtuXHEDwEz/rwx+WzE7NL+n35yO40t3kp0Sn5uMFwI94i91QZJmXHltMpZhrVLuYg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -899,7 +893,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.18.tgz",
|
||||
"integrity": "sha512-q6s5rEN1yYazpHYp+k4pboXRzMsRB9auzTRBEhyXSGYxqzrnn3qHN0DqgsLC9WAdyhCgnIEMFA8kRT+W277DqQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -940,7 +933,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.18.tgz",
|
||||
"integrity": "sha512-3CWejsEYr+ze+ktvWN/qHdyq5WLrj96QZpGYJyxh1pchIcpMPE9MmLpdjf0CUrWYB7g/85u0Geq/xsz72JrGng==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -985,7 +977,6 @@
|
||||
"integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@@ -2835,7 +2826,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.2.0.tgz",
|
||||
"integrity": "sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
@@ -2960,6 +2950,15 @@
|
||||
"node": ">=14.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ebarooni/capacitor-calendar": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@ebarooni/capacitor-calendar/-/capacitor-calendar-8.0.1.tgz",
|
||||
"integrity": "sha512-kwtvdYrcogioAkoft5Pcj/dkgsCaBUc4WVf3WoRBYQ92frfr4aNUSe+2Sd8ioA9ghrYAAdIPzTWzU4+tLU62iQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@es-joy/jsdoccomment": {
|
||||
"version": "0.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.46.0.tgz",
|
||||
@@ -3958,7 +3957,6 @@
|
||||
"integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/checkbox": "^4.2.1",
|
||||
"@inquirer/confirm": "^5.1.14",
|
||||
@@ -7081,7 +7079,6 @@
|
||||
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -7222,7 +7219,6 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -7330,7 +7326,6 @@
|
||||
"integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
@@ -7403,7 +7398,6 @@
|
||||
"integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
@@ -7688,7 +7682,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7764,7 +7757,6 @@
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -8463,7 +8455,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -10123,7 +10114,6 @@
|
||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10616,7 +10606,6 @@
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -11405,7 +11394,6 @@
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -12488,8 +12476,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.1.2.tgz",
|
||||
"integrity": "sha512-2oIUMGn00FdUiqz6epiiJr7xcFyNYj3rDcfmnzfkBnHyBQ3cBQUs4mmyGsOb7TTLb9kxk7dBcmEmqhDKkBoDyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jasmine-spec-reporter": {
|
||||
"version": "5.0.2",
|
||||
@@ -12538,7 +12525,6 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -12679,7 +12665,6 @@
|
||||
"integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "1.5.0",
|
||||
"body-parser": "^1.19.0",
|
||||
@@ -13257,7 +13242,6 @@
|
||||
"integrity": "sha512-kdTwsyRuncDfjEs0DlRILWNvxhDG/Zij4YLO4TMJgDLW+8OzpfkdPnRgrsRuY1o+oaxJGWsps5f/RVBgGmmN0w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"copy-anything": "^2.0.1",
|
||||
"parse-node-version": "^1.0.1",
|
||||
@@ -13402,7 +13386,6 @@
|
||||
"integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cli-truncate": "^4.0.0",
|
||||
"colorette": "^2.0.20",
|
||||
@@ -15300,7 +15283,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -16133,7 +16115,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
@@ -16227,7 +16208,6 @@
|
||||
"integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.0.2",
|
||||
@@ -17370,7 +17350,6 @@
|
||||
"integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.14.0",
|
||||
@@ -17572,8 +17551,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tuf-js": {
|
||||
"version": "4.1.0",
|
||||
@@ -17709,7 +17687,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -17975,7 +17952,6 @@
|
||||
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -18110,7 +18086,6 @@
|
||||
"integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.8",
|
||||
@@ -18213,7 +18188,6 @@
|
||||
"integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/bonjour": "^3.5.13",
|
||||
"@types/connect-history-api-fallback": "^1.5.4",
|
||||
@@ -19196,7 +19170,6 @@
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -19215,8 +19188,7 @@
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
|
||||
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aulas-unicaba",
|
||||
"version": "1.1.9",
|
||||
"version": "1.2.1",
|
||||
"author": "Ionic Framework",
|
||||
"homepage": "https://ionicframework.com/",
|
||||
"scripts": {
|
||||
@@ -35,6 +35,7 @@
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "8.0.1",
|
||||
"@capawesome/capacitor-file-picker": "^8.0.2",
|
||||
"@ebarooni/capacitor-calendar": "^8.0.1",
|
||||
"@ionic/angular": "^8.0.0",
|
||||
"@transistorsoft/capacitor-background-fetch": "^8.0.0",
|
||||
"ionicons": "^7.0.0",
|
||||
|
||||
@@ -6,7 +6,8 @@ Esta sección detalla el funcionamiento, alcance y compromiso de privacidad de l
|
||||
**Aulas UNICABA** es un visor especializado y autónomo para la cartelera de horarios, aulas y credencial de la Universidad de la Ciudad.
|
||||
- Permite consultar las materias, sus respectivos días, horarios y aulas asignadas.
|
||||
- Facilita el seguimiento de materias específicas mediante un sistema de **Favoritos**.
|
||||
- Avisa mediante **Notificaciones locales** si se detecta un cambio de aula en las materias marcadas como favoritas.
|
||||
- Avisa mediante **Notificaciones locales** si se detecta un cambio de aula en las materias marcadas como favoritas, y emite **Recordatorios Automáticos** programados 30 minutos antes de cada clase.
|
||||
- Gestiona una **Agenda de Exámenes e Hitos** integrada por materia (Parciales, Finales, TP), cuyos datos nunca abandonan tu celular.
|
||||
- **Opcionalmente**, permite acceder a tu **Credencial Digital** universitaria de los registros oficiales de forma segura e integrada.
|
||||
|
||||
## 2. ¿Cómo funciona? (Alcance Técnico)
|
||||
@@ -19,11 +20,11 @@ La aplicación ha sido diseñada bajo una arquitectura de "cliente liviano" con
|
||||
## 3. Privacidad Absoluta y Persistencia Avanzada
|
||||
- **Sin Intermediarios ni Datos Personales:** No retenemos u observamos nombres, correos ni datos sensibles vinculados a las cuentas y accesos del usuario. Ni Aulas UNICABA ni empresas externas rastrearán o vincularán tu cuenta.
|
||||
- **Sin Rastreadores (Zero Trackers):** Nuestra filosofía es estricta. Cero herramientas analíticas (No Google Analytics, no SDKs invasivos de Facebook o reportes crudos), sin métricas engorrosas y libre de publicidad en el código fuente.
|
||||
- **Persistencia Local Segura (CSV Offline):** Valoramos tus elecciones. Tus decisiones (Materias Favoritas) se guardan directamente como respaldo silencioso bajo un formato `.csv` estandarizado en la carpeta nativa interna "Documentos" del dispositivo (Respaldado por permisos explícitos de Almacenamiento y `Filesystem API`). **Esto permite que tus favoritos (y futuras agendas de exámenes) asombrosamente sobrevivan a desinstalaciones agresivas de la app o borrado de datos** sin depender y jamás tocar entornos remotos en la Nube.
|
||||
- **Persistencia Local Segura (CSV Offline):** Valoramos tus elecciones. Tus decisiones (Materias Favoritas y Agenda de Exámenes) se guardan directamente como respaldo silencioso bajo un formato `.csv` estandarizado en la carpeta nativa interna "Documentos" del dispositivo (Respaldado por permisos explícitos de Almacenamiento y `Filesystem API`). **Esto permite que tus favoritos y tu agenda asombrosamente sobrevivan a desinstalaciones agresivas de la app o borrado de datos** sin depender y jamás tocar entornos remotos en la Nube.
|
||||
- **Caché Inteligente (Modo Offline):** Entendemos que la conectividad en facultades y subtes es delicada. Toda la cartelera central y tu Credencial QR se empaquetan en `Base64` utilizando memorias volátiles locales (`Preferences API`). Es decir, puedes mostrar qué materia debes rendir o exhibir tu credencial QR instantáneamente sin red (Modo Avión o Sin Señal).
|
||||
|
||||
## 4. Compromiso de Transparencia
|
||||
Esta innovación tecnológica es de carácter informativo. La precisión estricta de asignación de las aulas depende originariamente de la pre-carga impuesta por el cuerpo no-docente en el documento de Spreadsheets. En escenarios contradictorios, la palabra oficial del ala administrativa universitaria primará sobre la respuesta sincronizada en Aulas UNICABA.
|
||||
|
||||
---
|
||||
*Última actualización: 25 de Marzo, 2026 - v1.1.5*
|
||||
*Última actualización: 27 de Marzo, 2026 - v1.1.9*
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}␍
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||