diff --git a/.env.example b/.env.example index e66bfee..f301e49 100644 --- a/.env.example +++ b/.env.example @@ -1 +1 @@ -MI_UTEM_API_DEBUG=https://api-mi-utem.herokuapp.com/ \ No newline at end of file +MI_UTEM_API_DEBUG=https://api.exdev.cl \ No newline at end of file diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..2b19323 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,4 @@ +{ + "flutter": "3.7.12", + "flavors": {} +} \ No newline at end of file diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml new file mode 100644 index 0000000..9cbb387 --- /dev/null +++ b/.github/workflows/dev.yml @@ -0,0 +1,103 @@ +name: Publicar Aplicación Beta +on: + pull_request: + types: [opened, reopened, synchronize, closed] + +jobs: + build: + # Formato de comentario: /deploy-beta + if: | + github.event.pull_request.author_association == 'MEMBER' && + (github.event.pull_request.state == 'open' && !github.event.pull_request.draft) && + (!github.event.pull_request.locked && !github.event.pull_request.active_lock_reason) && + (!github.event.pull_request.merged_at) + runs-on: macos-latest + environment: development + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + - name: Instalar Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3.0' + bundler-cache: true + - name: Instalar Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + - name: Instalar Flutter + uses: flutter-actions/setup-flutter@v3 + with: + channel: stable + version: 3.7.12 + cache: true + cache-sdk: true + cache-key: mi-utem + - name: Instalar Dependencias + run: flutter pub get + - name: Copiar .env + run: cp .env.example .env + - name: Generar Archivos + env: + GOOGLE_SERVICES: ${{ secrets.GOOGLE_SERVICES }} + API_PLAY_STORE: ${{ secrets.API_PLAY_STORE }} + KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }} + KEYSTORE_FILE: ${{ secrets.KEYSTORE_FILE }} + APP_STORE_CONNECT_AUTHKEY: ${{ secrets.APP_STORE_CONNECT_AUTHKEY }} + GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.GOOGLE_SERVICE_INFO_PLIST }} + run: | + echo $GOOGLE_SERVICES | base64 --decode > android/app/google-services.json + echo $API_PLAY_STORE | base64 --decode > android/api-playstore.json + + mkdir -p android/keystores + chmod 755 android/keystores + + echo $KEYSTORE_PROPERTIES | base64 --decode > android/keystores/key.properties + echo $KEYSTORE_FILE | base64 --decode > android/keystores/keystore.jks + + chmod 644 android/keystores/key* + + echo $APP_STORE_CONNECT_AUTHKEY | base64 --decode > ios/fastlane/AuthKey.p8 + echo $GOOGLE_SERVICE_INFO_PLIST > ios/Runner/GoogleService-Info.plist + - name: Publicar Beta + uses: maierj/fastlane-action@v3.1.0 + env: + APP_IDENTIFIER_IOS: ${{ secrets.APP_IDENTIFIER_IOS }} + APP_IDENTIFIER_ANDROID: ${{ secrets.APP_IDENTIFIER_ANDROID }} + + SLACK_URL: ${{ secrets.SLACK_URL }} + APPLE_ID: ${{ secrets.APPLE_ID }} + + FASTLANE_USER: ${{ secrets.FASTLANE_USER }} + FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }} + FASTLANE_ITC_TEAM_ID: ${{ secrets.FASTLANE_ITC_TEAM_ID }} + FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }} + FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} + + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + MATCH_REPO_GIT_URL: ${{ secrets.MATCH_REPO_GIT_URL }} + MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} + MATCH_KEYCHAIN_PASSWORD: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + + KEYCHAIN_NAME: ${{ secrets.KEYCHAIN_NAME }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + + FASTLANE_SESSION: ${{ secrets.FASTLANE_SESSION }} + + APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + with: + lane: 'upload' + options: '{ "type": "beta", "skip_git_push": "true", "skip_slack": "true", "is_ci": "true" }' + - name: Publicar Version en Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pubspec.yaml + git commit -m "ci(bump-version): github action bump version" + git push \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4421ce2..11b0d65 100644 --- a/.gitignore +++ b/.gitignore @@ -1060,7 +1060,6 @@ FodyWeavers.xsd # Custom ignores *.pem -*.code-workspace **/prod/google-services.json android/app/google-services.json api-playstore.json @@ -1068,4 +1067,6 @@ GoogleService-Info*.plist !GoogleService-Info-dev.plist android/keystores/* */fastlane/report.xml -.flutter-plugins-dependencies \ No newline at end of file + +# Llave de API de App Store Connect +**/ios/**/*.p8 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7100e0a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dart.flutterSdkPath": ".fvm/versions/3.7.12" +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ba86ae6..a6b6f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,9 +28,33 @@ Tipos de cambios ## [Unreleased] +### Added +- Apodo para personalizar la aplicación. +- Se agrega onboarding (con configuración de apodo, solicitud de permiso de notificaciones y bienvenida a la app). +- Lista de estudiantes al resumen de asignatura. +- Vista previa de estudiantes y profesores. +- Vista previa de los datos del profesor. +- Se agrega ventana de vista previa de la asignatura desde horario (al mantener presionado un bloque). +- Se agrega navegación hacia la asignatura desde el horario al presionar un bloque. +- Botón para limpiar las notas en la calculadora. +- Sección de `Acerca de la App` en modo depuración. +- Se agrega métricas y toma de errores de Sentry (esta vez están bien configurados). +- Se agrega modo fuera de línea para permitir el uso de la app sin conexión. +- Se permite cualquier orientación de pantalla (horizontal y vertical). + ### Changed +- Se actualizaron algunas dependencias. +- Se ordenan las clases y widgets de la app. +- Se optimiza el código y la aplicación en general. +- Se separan clases en formato de repositorios, servicios y controladores. +- Se mejora la documentación de algunos archivos y métodos. +- Ahora se cargan las noticias de la página [noticias.utem.cl](https://noticias.utem.cl). +- Se utiliza navegación de flutter nativa (Usando [`Navigator`](https://docs.flutter.dev/cookbook/navigation/navigation-basics) en lugar de rutas nombradas). -- Se actualizan dependecias de Flutter +### Removed +- Se elimina pestaña de estudiantes del detalle de asignatura (a favor del botón de lista de estudiantes en el resumen). +- Modal de Permitir Notificaciones a favor de modal nativa del sistema. Solo se editará el mensaje de notificaciones, ya que es redundante el botón de permitir y cancelar. +- Vistas sin uso en la aplicación. ## [2.11.9] - 2023-10-11Z @@ -135,8 +159,3 @@ Esta versión del changelog contiene cambios hechos en 2.10, debido a que no se - Lista de estudiantes en la pantalla de asignatura - Perfil de profesores -- Perfil de profesores -- Perfil de profesores -- Perfil de profesores -- Perfil de profesores -- Perfil de profesores diff --git a/Gemfile b/Gemfile index e4b5be5..4812693 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,7 @@ source "https://rubygems.org" gem 'fastlane' gem 'cocoapods' +gem 'abbrev' plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock index 9b45670..4957f90 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,48 +1,57 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.5) + CFPropertyList (3.0.7) + base64 + nkf rexml - activesupport (6.1.6) + abbrev (0.1.2) + activesupport (7.1.3.2) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) - artifactory (3.0.15) + artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.2.0) - aws-partitions (1.570.0) - aws-sdk-core (3.130.0) - aws-eventstream (~> 1, >= 1.0.2) - aws-partitions (~> 1, >= 1.525.0) - aws-sigv4 (~> 1.1) - jmespath (~> 1.0) - aws-sdk-kms (1.55.0) - aws-sdk-core (~> 3, >= 3.127.0) + aws-eventstream (1.3.0) + aws-partitions (1.907.0) + aws-sdk-core (3.191.6) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.651.0) + aws-sigv4 (~> 1.8) + jmespath (~> 1, >= 1.6.1) + aws-sdk-kms (1.78.0) + aws-sdk-core (~> 3, >= 3.191.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.113.0) - aws-sdk-core (~> 3, >= 3.127.0) + aws-sdk-s3 (1.146.1) + aws-sdk-core (~> 3, >= 3.191.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.4) - aws-sigv4 (1.4.0) + aws-sigv4 (~> 1.8) + aws-sigv4 (1.8.0) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) + base64 (0.2.0) + bigdecimal (3.1.7) claide (1.1.0) - cocoapods (1.11.3) + cocoapods (1.15.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.11.3) + cocoapods-core (= 1.15.2) cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 1.4.0, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.4.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) cocoapods-try (>= 1.1.0, < 2.0) colored2 (~> 3.1) escape (~> 0.0.4) @@ -50,10 +59,10 @@ GEM gh_inspector (~> 1.0) molinillo (~> 0.8.0) nap (~> 1.0) - ruby-macho (>= 1.0, < 3.0) - xcodeproj (>= 1.21.0, < 2.0) - cocoapods-core (1.11.3) - activesupport (>= 5.0, < 7) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.23.0, < 2.0) + cocoapods-core (1.15.2) + activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) concurrent-ruby (~> 1.1) @@ -63,7 +72,7 @@ GEM public_suffix (~> 4.0) typhoeus (~> 1.0) cocoapods-deintegrate (1.0.5) - cocoapods-downloader (1.6.3) + cocoapods-downloader (2.1) cocoapods-plugins (1.0.0) nap cocoapods-search (1.0.1) @@ -75,19 +84,20 @@ GEM colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) - concurrent-ruby (1.1.10) + concurrent-ruby (1.2.3) + connection_pool (2.4.1) declarative (0.0.20) - digest-crc (0.6.4) + digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) - dotenv (2.7.6) + domain_name (0.6.20240107) + dotenv (2.8.1) + drb (2.2.1) emoji_regex (3.2.3) escape (0.0.4) - ethon (0.15.0) + ethon (0.16.0) ffi (>= 1.15.0) - excon (0.92.1) - faraday (1.10.0) + excon (0.110.0) + faraday (1.10.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -106,8 +116,8 @@ GEM faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.3) - multipart-post (>= 1.2, < 3) + faraday-multipart (1.0.4) + multipart-post (~> 2) faraday-net_http (1.0.1) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) @@ -115,15 +125,15 @@ GEM faraday-retry (1.0.3) faraday_middleware (1.2.0) faraday (~> 1.0) - fastimage (2.2.6) - fastlane (2.205.1) + fastimage (2.3.1) + fastlane (2.220.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) aws-sdk-s3 (~> 1.0) babosa (>= 1.0.3, < 2.0.0) bundler (>= 1.12.0, < 3.0.0) - colored + colored (~> 1.2) commander (~> 4.6) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) @@ -135,34 +145,36 @@ GEM gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) google-cloud-storage (~> 1.31) highline (~> 2.0) + http-cookie (~> 1.0.5) json (< 3.0.0) jwt (>= 2.1.0, < 3) mini_magick (>= 4.9.4, < 5.0.0) - multipart-post (~> 2.0.0) + multipart-post (>= 2.0.0, < 3.0.0) naturally (~> 2.2) - optparse (~> 0.1.1) + optparse (>= 0.1.1, < 1.0.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.3) + security (= 0.1.5) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.0.0) + terminal-table (~> 3) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) - xcpretty-travis-formatter (>= 0.0.3) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-plugin-changelog (0.16.0) - ffi (1.15.5) + ffi (1.16.3) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.16.0) - google-apis-core (>= 0.4, < 2.a) - google-apis-core (0.4.2) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -170,98 +182,94 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.a) rexml - webrick - google-apis-iamcredentials_v1 (0.10.0) - google-apis-core (>= 0.4, < 2.a) - google-apis-playcustomapp_v1 (0.7.0) - google-apis-core (>= 0.4, < 2.a) - google-apis-storage_v1 (0.11.0) - google-apis-core (>= 0.4, < 2.a) - google-cloud-core (1.6.0) - google-cloud-env (~> 1.0) + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.7.0) + google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.2.0) - google-cloud-storage (1.36.1) + google-cloud-errors (1.4.0) + google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - googleauth (1.1.2) + googleauth (1.8.1) faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) - memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.4) + http-cookie (1.0.5) domain_name (~> 0.5) httpclient (2.8.3) - i18n (1.10.0) + i18n (1.14.4) concurrent-ruby (~> 1.0) - jmespath (1.6.1) - json (2.6.1) - jwt (2.3.0) - memoist (0.16.2) - mini_magick (4.11.0) - mini_mime (1.1.2) - minitest (5.15.0) + jmespath (1.6.2) + json (2.7.2) + jwt (2.8.1) + base64 + mini_magick (4.12.0) + mini_mime (1.1.5) + minitest (5.22.3) molinillo (0.8.0) multi_json (1.15.0) - multipart-post (2.0.0) + multipart-post (2.4.0) + mutex_m (0.2.0) nanaimo (0.3.0) nap (1.1.0) naturally (2.2.1) netrc (0.11.0) - optparse (0.1.1) + nkf (0.2.0) + optparse (0.4.0) os (1.1.4) - plist (3.6.0) - public_suffix (4.0.6) - rake (13.0.6) - representable (3.1.1) + plist (3.7.1) + public_suffix (4.0.7) + rake (13.2.0) + representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.2.5) + rexml (3.2.6) rouge (2.0.7) ruby-macho (2.5.1) ruby2_keywords (0.0.5) rubyzip (2.3.2) - security (0.1.3) - signet (0.16.1) + security (0.1.5) + signet (0.19.0) addressable (~> 2.8) - faraday (>= 0.17.5, < 3.0) + faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) - simctl (1.6.8) + simctl (1.6.10) CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) trailblazer-option (0.1.2) tty-cursor (0.7.1) - tty-screen (0.8.1) + tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) - typhoeus (1.4.0) + typhoeus (1.4.1) ethon (>= 0.9.0) - tzinfo (2.0.4) + tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.1) - unicode-display_width (1.8.0) - webrick (1.7.0) + unicode-display_width (2.5.0) word_wrap (1.0.0) - xcodeproj (1.21.0) + xcodeproj (1.24.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) @@ -272,17 +280,18 @@ GEM rouge (~> 2.0.7) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) - zeitwerk (2.5.4) PLATFORMS + arm64-darwin-22 x86_64-darwin-19 x86_64-darwin-20 x86_64-darwin-21 DEPENDENCIES + abbrev cocoapods fastlane fastlane-plugin-changelog BUNDLED WITH - 2.3.7 + 2.5.7 diff --git a/README.md b/README.md index 71eeefb..66b9cb1 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,71 @@ Aplicación multiplataforma hecha por estudiantes de la [Universidad Tecnológica Metropolitana de Chile](https://www.utem.cl/) enfocada en adaptar la [plataforma académica Mi.UTEM](https://mi.utem.cl/) de la institución a dispositivos móviles. ## Requisitos técnicos -- Flutter 3.13.6 +- Flutter 3.7.12 (te recomendamos utilizar [fvm](https://fvm.app) para facilitar la administración de versiones) +- macOS + XCode (para compilar en iOS) +- Android Studio (para compilar en Android) +- ruby 3.3.0 (para compilar y subir app a App Store y Google Play) + +## Organización de carpetas +``` +|-- lib +| |-- config (Configuración de la aplicación) +| |-- controllers (Controladores de la aplicación, para procesar datos de una vista especifica) +| |-- models (Modelos de datos) +| |-- repositories (Repositorios de datos, para obtener datos desde la API) +| |-- screens (Pantallas de la aplicación) +| |-- services (Servicios de la aplicación, maneja y procesa los datos de repositorios) +| |-- themes (Temas de la aplicación) +| |-- utils (Utilidades de la aplicación) +| |-- widgets (Widgets de la aplicación) +|-- main.dart (Punto de entrada de la aplicación) +|-- service_manager.dart (Registra los servicios de la aplicación) +``` + +### Construcción de la app + +Para construir la app utilizamos fastlane. +Comienza revisando la documentación de Fastlane para [iOS](https://docs.fastlane.tools/getting-started/ios/setup/) y [Android](https://docs.fastlane.tools/getting-started/android/setup/). +Utilizaremos la instalación de bundler para instalar las dependencias y ejecutar fastlane. + +
+Instalación de Bundler + +(Se asume que tienes instalado Ruby 3.3.0 o superior) +```bash +gem install bundler +``` +
+ +
+Instalación de las Dependencias + +Una vez instalado ejecutarás este comando para instalar las dependencias: +```bash +bundle install +``` +
+ +
+Subiendo una nueva Actualización + +Para construir la app ejecuta este comando: +```bash +bundle exec fastlane upload type:"beta" skip_ios:false skip_android:false skip_clean:true skip_cocoapods:true skip_git_push:true skip_slack:true is_ci:true +``` +Este comando subirá el archivo binario a AppStore y Google Play. Esto hacen las variables: +- `skip_ios`: Si es `true` no subirá la app a AppStore, si es `false` subirá la app a AppStore. +- `skip_android`: Si es `true` no subirá la app a Google Play, si es `false` subirá la app a Google Play. +- `skip_clean`: Si es `true` no limpiará los archivos temporales, si es `false` limpiará los archivos temporales (se recomienda utilizar para construcciones en producción y evitar problemas con archivos guardados en caché). +- `skip_cocoapods`: Si es `true` no instalará las dependencias de CocoaPods, si es `false` instalará las dependencias de CocoaPods (se recomienda utilizar para construcciones en producción y evitar problemas con dependencias de CocoaPods). +- `skip_git_push`: Si es `true` no creará una nueva etiqueta, si es `false` creará una nueva etiqueta con la lista de cambios formateada. +- `skip_slack`: Si es `true` no enviará un mensaje a Slack, si es `false` enviará un mensaje a Slack. +- `is_ci`: Si es `true` se ejecutará en modo de integración continua, es decir, no esperará a ver si la versión aparece en AppStore o Google Play, si es `false` esperará a ver si la versión aparece en AppStore o Google Play. Además, al ser `true` también evitará editar el repositorio de Match (el cual contiene los certificados de distribución de la app). +
## Créditos Este proyecto fue creado por el Club de Desarrollo Experimental (ExDev) de la Universidad Tecnológica Metropolitana y es mantenido por los propios estudiantes con el apoyo del equipo de SISEI. Mira los perfiles que han contribuido a este proyecto: - + Contribuidores \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index fe0da47..77e7f4e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -37,7 +37,6 @@ android { compileSdkVersion rootProject.ext.compileSdkVersion defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "cl.inndev.miutem" minSdkVersion 23 multiDexEnabled true diff --git a/android/build.gradle b/android/build.gradle index 74e4842..ca02cfb 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,8 +1,8 @@ buildscript { ext.kotlin_version = '1.7.21' ext { - compileSdkVersion = 33 // or latest - targetSdkVersion = 33 // or latest + compileSdkVersion = 34 // or latest + targetSdkVersion = 34 // or latest appCompatVersion = "1.4.2" // or latest } repositories { diff --git a/fastlane/.env.example b/fastlane/.env.example index f06b1b3..96dddc9 100644 --- a/fastlane/.env.example +++ b/fastlane/.env.example @@ -1,15 +1,20 @@ -SLACK_URL=https://hooks.slack.com/services/ASDFG... +APP_IDENTIFIER_IOS=cl.utem.miutem +APP_IDENTIFIER_ANDROID=cl.inndev.miutem + +SLACK_URL=https://hooks.slack.com/services/... APPLE_ID=exdev@utem.cl + FASTLANE_USER=exdev@utem.cl -FASTLANE_PASSWORD=apple_id_password +FASTLANE_PASSWORD= FASTLANE_ITC_TEAM_ID=122942314 FASTLANE_TEAM_ID=DS265QHA5J -FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=app_specific_password -MATCH_PASSWORD=match_password -GITHUB_TOKEN=github_token -APP_IDENTIFIER_IOS=com.app.identifier_ios -APP_IDENTIFIER_ANDROID=com.app.identifier_android -MATCH_REPO_GIT_URL=git@github.com:certificates-repo - -KEYCHAIN_PASSWORD=tempKeychainPassword -KEYCHAIN_NAME=tempKeychainName \ No newline at end of file +FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD= + +GITHUB_TOKEN= + +MATCH_REPO_GIT_URL=git@github.com:example/examplerepo.git +MATCH_GIT_BASIC_AUTHORIZATION= +MATCH_KEYCHAIN_PASSWORD= + +KEYCHAIN_NAME=login.keychain +KEYCHAIN_PASSWORD= diff --git a/fastlane/Fastfile b/fastlane/Fastfile index fa0e814..fa0ccea 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -42,40 +42,92 @@ platform :mobile do changelog end - lane :test do - puts("Testing Fastlane for mobile") + lane :test do |options| + skip_slack = false + skip_android_test = false + skip_ios_test = false + + if options[:skip_slack] + skip_slack = options[:skip_slack] + end + + if options[:skip_android_test] + skip_android_test = options[:skip_android_test] + end + + if options[:skip_ios_test] + skip_ios_test = options[:skip_ios_test] + end + + puts "Testing Fastlane for mobile" changelog = read_changelog emojified_changelog = emojify_changelog - + changelog = translate_changelog(changelog: changelog) emojified_changelog = translate_changelog(changelog: emojified_changelog) puts "Changelog: #{changelog}" puts "Emojified Changelog: #{emojified_changelog}" - android_test - ios_test + unless skip_android_test + android_test + end - slack( - message: "Este es un mensaje de prueba desde Fastlane", - slack_url: ENV["SLACK_URL"], - channel: "#proy-mi-utem-bots", - default_payloads: [], - attachment_properties: { - fields: [ - { - title: "Campo", - value: "Prueba" + unless skip_ios_test + ios_test + end + + unless skip_slack + slack( + message: "Este es un mensaje de prueba desde Fastlane", + channel: "proy-mi-utem-bots", + default_payloads: [], + attachment_properties: { + fields: [ + { + title: "Campo", + value: "Prueba" + }, + ] }, - ] - }, - ) + ) + end + end + + desc "Lee el archivo ../pubspec.yaml, obtiene la version y su numero de compilación" + lane :get_pubspec_version do + pubspec = File.read("../pubspec.yaml") + version = pubspec.match(/version: (.+)/)[1] + build_name = version.match(/(\d+\.\d+\.\d+)/)[1] + build_number = ((version.match(/(\d+)$/)[1]).to_i + 1).to_s + + { + "build_name" => build_name, + "build_number" => build_number + } + end + + desc "Incrementa el numero de compilación en el archivo ../pubspec.yaml" + lane :increment_pubspec_build_number do + version = get_pubspec_version() + build_number = version["build_number"] + build_name = version["build_name"] + + puts "Incrementando el numero de compilación en el archivo ../pubspec.yaml" + new_version = "version: #{build_name}+#{build_number}" + + pubspec = File.read("../pubspec.yaml") + pubspec = pubspec.gsub(/version: (.+)/, new_version) + File.write("../pubspec.yaml", pubspec) + + puts "Numero de compilación incrementado en el archivo ../pubspec.yaml" end lane :upload do |options| - build_number = options[:build_number] - build_name = options[:build_name] + version = get_pubspec_version() + build_number = version["build_number"] # Obtiene la version del pubspec.yaml + build_name = version["build_name"] # Obtiene el numero de compilación del pubspec.yaml changelog = read_changelog emojified_changelog = emojify_changelog changelog = translate_changelog(changelog: changelog) @@ -89,6 +141,17 @@ platform :mobile do skip_git_push = false skip_slack = false is_ci = false + increment_build_number = false + + puts "Construyendo app con version #{build_name} (#{build_number})" + + if options[:build_number] + build_number = options[:build_number] + end + + if options[:build_name] + build_name = options[:build_name] + end if options[:type] == "release" || options[:type] == "beta" type = options[:type] @@ -126,6 +189,10 @@ platform :mobile do is_ci = options[:is_ci] end + if options[:increment_build_number] + increment_build_number = options[:increment_build_number] + end + if build_name build_name = build_name.tr('v', '') end @@ -154,33 +221,34 @@ platform :mobile do ) end - stamp_changelog( - section_identifier: build_name, - git_tag: build_name, - ) - - unless skip_git_push - push_to_git_remote - git_add - git_commit(path: "*", message: "Stamp changelog for #{build_name} (#{build_number})") - push_to_git_remote - - github_release = set_github_release( - repository_name: "exdevutem/mi-utem", - api_token: ENV["GITHUB_TOKEN"], - name: "v#{build_name}", - tag_name: "v#{build_name}", - description: emojified_changelog, - commitish: "dev", - upload_assets: ["./build/app/outputs/bundle/release/app-release.aab", "./build/app/outputs/apk/release/app-release.apk"] + if options[:type] == "release" + stamp_changelog( + section_identifier: build_name, + git_tag: build_name, ) + + unless skip_git_push + push_to_git_remote + git_add + git_commit(path: "*", message: "Stamp changelog for #{build_name} (#{build_number})") + push_to_git_remote + + github_release = set_github_release( + repository_name: "exdevutem/mi-utem", + api_token: ENV["GITHUB_TOKEN"], + name: "v#{build_name}+#{build_number}", + tag_name: "v#{build_name}+#{build_number}", + description: emojified_changelog, + commitish: "dev", + upload_assets: ["./build/app/outputs/bundle/release/app-release.aab", "./build/app/outputs/apk/release/app-release.apk"] + ) + end end unless skip_slack slack( message: "🚀 Se ha publicado la nueva versión #{build_name} (#{build_number})", - slack_url: ENV["SLACK_URL"], - channel: "#proy-mi-utem", + channel: "proy-mi-utem", default_payloads: [], attachment_properties: { fields: [ @@ -196,5 +264,10 @@ platform :mobile do }, ) end + + puts "App construida y publicada con éxito" + unless increment_build_number + increment_pubspec_build_number() + end end end \ No newline at end of file diff --git a/ios/Podfile b/ios/Podfile index 16c1ff0..1cdce05 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -34,6 +34,7 @@ target 'Runner' do use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + pod 'GoogleUtilities', :git => 'https://github.com/google/GoogleUtilities.git' end post_install do |installer| @@ -112,6 +113,7 @@ target 'MiUtemNotificationServiceExtension' do use_modular_headers! install_awesome_fcm_ios_pod_target File.dirname(File.realpath(__FILE__)) + pod 'GoogleUtilities', :git => 'https://github.com/google/GoogleUtilities.git' end update_awesome_fcm_service_target('MiUtemNotificationServiceExtension', File.dirname(File.realpath(__FILE__)), flutter_root) ################ Awesome Notifications FCM pod mod ################### \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1a54867..c21e7ce 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -596,18 +596,18 @@ PODS: - abseil/base/base_internal - abseil/base/config - abseil/meta/type_traits - - awesome_notifications (0.0.5): + - awesome_notifications (0.8.1): - Flutter - - IosAwnCore (= 0.7.3) + - IosAwnCore (~> 0.8.0) - awesome_notifications_core (0.0.1): - Flutter - - awesome_notifications_fcm (0.7.3): + - awesome_notifications_fcm (0.8.1): - awesome_notifications - - Firebase - Firebase/Messaging + - FirebaseCore - Flutter - - IosAwnCore (= 0.7.3) - - IosAwnFcmDist (= 0.7.5) + - IosAwnCore (~> 0.8.0) + - IosAwnFcmDist (~> 0.8.0) - background_fetch (1.2.1): - Flutter - BoringSSL-GRPC (0.0.24): @@ -621,8 +621,6 @@ PODS: - firebase_core - Flutter - nanopb (< 2.30910.0, >= 2.30908.0) - - Firebase (10.7.0): - - Firebase/Core (= 10.7.0) - Firebase/Analytics (10.7.0): - Firebase/Core - Firebase/Core (10.7.0): @@ -630,6 +628,9 @@ PODS: - FirebaseAnalytics (~> 10.7.0) - Firebase/CoreOnly (10.7.0): - FirebaseCore (= 10.7.0) + - Firebase/Crashlytics (10.7.0): + - Firebase/CoreOnly + - FirebaseCrashlytics (~> 10.7.0) - Firebase/Firestore (10.7.0): - Firebase/CoreOnly - FirebaseFirestore (~> 10.7.0) @@ -646,9 +647,13 @@ PODS: - Firebase/Analytics (= 10.7.0) - firebase_core - Flutter - - firebase_core (2.11.0): + - firebase_core (2.16.0): - Firebase/CoreOnly (= 10.7.0) - Flutter + - firebase_crashlytics (3.3.6): + - Firebase/Crashlytics (= 10.7.0) + - firebase_core + - Flutter - firebase_in_app_messaging (0.7.0-15): - Firebase/InAppMessaging (= 10.7.0) - firebase_core @@ -657,7 +662,7 @@ PODS: - Firebase/RemoteConfig (= 10.7.0) - firebase_core - Flutter - - FirebaseABTesting (10.16.0): + - FirebaseABTesting (10.23.0): - FirebaseCore (~> 10.0) - FirebaseAnalytics (10.7.0): - FirebaseAnalytics/AdIdSupport (= 10.7.0) @@ -681,8 +686,18 @@ PODS: - FirebaseCoreInternal (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/Logger (~> 7.8) + - FirebaseCoreExtension (10.23.0): + - FirebaseCore (~> 10.0) - FirebaseCoreInternal (10.16.0): - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseCrashlytics (10.7.0): + - FirebaseCore (~> 10.5) + - FirebaseInstallations (~> 10.0) + - FirebaseSessions (~> 10.5) + - GoogleDataTransport (~> 9.2) + - GoogleUtilities/Environment (~> 7.8) + - nanopb (< 2.30910.0, >= 2.30908.0) + - PromisesObjC (~> 2.1) - FirebaseFirestore (10.7.0): - abseil/algorithm (~> 1.20211102.0) - abseil/base (~> 1.20211102.0) @@ -702,7 +717,7 @@ PODS: - FirebaseInstallations (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - nanopb (< 2.30910.0, >= 2.30908.0) - - FirebaseInstallations (10.16.0): + - FirebaseInstallations (10.23.0): - FirebaseCore (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) @@ -722,9 +737,15 @@ PODS: - FirebaseInstallations (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseSessions (10.23.0): + - FirebaseCore (~> 10.5) + - FirebaseCoreExtension (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleDataTransport (~> 9.2) + - GoogleUtilities/Environment (~> 7.10) + - nanopb (< 2.30911.0, >= 2.30908.0) + - PromisesSwift (~> 2.1) - Flutter (1.0.0) - - flutter_keyboard_visibility (0.0.1): - - Flutter - flutter_secure_storage (6.0.0): - Flutter - flutter_uxcam (2.4.5): @@ -753,29 +774,54 @@ PODS: - GoogleUtilities/Network (~> 7.8) - "GoogleUtilities/NSData+zlib (~> 7.8)" - nanopb (< 2.30910.0, >= 2.30908.0) - - GoogleDataTransport (9.2.5): + - GoogleDataTransport (9.4.1): - GoogleUtilities/Environment (~> 7.7) - - nanopb (< 2.30910.0, >= 2.30908.0) + - nanopb (< 2.30911.0, >= 2.30908.0) - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/AppDelegateSwizzler (7.11.5): + - GoogleUtilities (7.13.0): + - GoogleUtilities/AppDelegateSwizzler (= 7.13.0) + - GoogleUtilities/Environment (= 7.13.0) + - GoogleUtilities/ISASwizzler (= 7.13.0) + - GoogleUtilities/Logger (= 7.13.0) + - GoogleUtilities/MethodSwizzler (= 7.13.0) + - GoogleUtilities/Network (= 7.13.0) + - "GoogleUtilities/NSData+zlib (= 7.13.0)" + - GoogleUtilities/Privacy (= 7.13.0) + - GoogleUtilities/Reachability (= 7.13.0) + - GoogleUtilities/SwizzlerTestHelpers (= 7.13.0) + - GoogleUtilities/UserDefaults (= 7.13.0) + - GoogleUtilities/AppDelegateSwizzler (7.13.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - - GoogleUtilities/Environment (7.11.5): + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (7.13.0): + - GoogleUtilities/Privacy - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.11.5): + - GoogleUtilities/ISASwizzler (7.13.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (7.13.0): - GoogleUtilities/Environment - - GoogleUtilities/MethodSwizzler (7.11.5): + - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (7.13.0): - GoogleUtilities/Logger - - GoogleUtilities/Network (7.11.5): + - GoogleUtilities/Privacy + - GoogleUtilities/Network (7.13.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.11.5)" - - GoogleUtilities/Reachability (7.11.5): + - "GoogleUtilities/NSData+zlib (7.13.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (7.13.0) + - GoogleUtilities/Reachability (7.13.0): - GoogleUtilities/Logger - - GoogleUtilities/UserDefaults (7.11.5): + - GoogleUtilities/Privacy + - GoogleUtilities/SwizzlerTestHelpers (7.13.0): + - GoogleUtilities/MethodSwizzler + - GoogleUtilities/UserDefaults (7.13.0): - GoogleUtilities/Logger + - GoogleUtilities/Privacy - "gRPC-C++ (1.44.0)": - "gRPC-C++/Implementation (= 1.44.0)" - "gRPC-C++/Interface (= 1.44.0)" @@ -833,10 +879,12 @@ PODS: - Flutter - in_app_review (0.2.0): - Flutter - - IosAwnCore (0.7.3) - - IosAwnFcmDist (0.7.5): - - IosAwnCore (= 0.7.3) - - leveldb-library (1.22.2) + - IosAwnCore (0.8.0) + - IosAwnFcmDist (0.8.0): + - FirebaseCore + - FirebaseMessaging + - IosAwnCore (~> 0.8.0) + - leveldb-library (1.22.4) - Libuv-gRPC (0.0.10): - Libuv-gRPC/Implementation (= 0.0.10) - Libuv-gRPC/Interface (= 0.0.10) @@ -855,14 +903,18 @@ PODS: - FlutterMacOS - permission_handler_apple (9.1.1): - Flutter - - PromisesObjC (2.3.1) - - Sentry/HybridSDK (8.11.0): - - SentryPrivate (= 8.11.0) - - sentry_flutter (0.0.1): + - PromisesObjC (2.4.0) + - PromisesSwift (2.4.0): + - PromisesObjC (= 2.4.0) + - screen_protector (1.2.1): + - Flutter + - ScreenProtectorKit (~> 1.3.1) + - ScreenProtectorKit (1.3.1) + - Sentry/HybridSDK (8.25.2) + - sentry_flutter (8.2.0): - Flutter - FlutterMacOS - - Sentry/HybridSDK (= 8.11.0) - - SentryPrivate (8.11.0) + - Sentry/HybridSDK (= 8.25.2) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -873,7 +925,7 @@ PODS: - FMDB (>= 2.7.5) - url_launcher_ios (0.0.1): - Flutter - - UXCam (3.6.6) + - UXCam (3.6.11) - video_player_avfoundation (0.0.1): - Flutter @@ -885,21 +937,23 @@ DEPENDENCIES: - cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) - firebase_in_app_messaging (from `.symlinks/plugins/firebase_in_app_messaging/ios`) - firebase_remote_config (from `.symlinks/plugins/firebase_remote_config/ios`) - Flutter (from `Flutter`) - - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_uxcam (from `.symlinks/plugins/flutter_uxcam/ios`) + - GoogleUtilities (from `https://github.com/google/GoogleUtilities.git`) - image_editor_common (from `.symlinks/plugins/image_editor_common/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - in_app_review (from `.symlinks/plugins/in_app_review/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - screen_protector (from `.symlinks/plugins/screen_protector/ios`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`) @@ -912,16 +966,18 @@ SPEC REPOS: - FirebaseABTesting - FirebaseAnalytics - FirebaseCore + - FirebaseCoreExtension - FirebaseCoreInternal + - FirebaseCrashlytics - FirebaseFirestore - FirebaseInAppMessaging - FirebaseInstallations - FirebaseMessaging - FirebaseRemoteConfig + - FirebaseSessions - FMDB - GoogleAppMeasurement - GoogleDataTransport - - GoogleUtilities - "gRPC-C++" - gRPC-Core - IosAwnCore @@ -930,8 +986,9 @@ SPEC REPOS: - Libuv-gRPC - nanopb - PromisesObjC + - PromisesSwift + - ScreenProtectorKit - Sentry - - SentryPrivate - UXCam EXTERNAL SOURCES: @@ -949,18 +1006,20 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/firebase_analytics/ios" firebase_core: :path: ".symlinks/plugins/firebase_core/ios" + firebase_crashlytics: + :path: ".symlinks/plugins/firebase_crashlytics/ios" firebase_in_app_messaging: :path: ".symlinks/plugins/firebase_in_app_messaging/ios" firebase_remote_config: :path: ".symlinks/plugins/firebase_remote_config/ios" Flutter: :path: Flutter - flutter_keyboard_visibility: - :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" flutter_uxcam: :path: ".symlinks/plugins/flutter_uxcam/ios" + GoogleUtilities: + :git: https://github.com/google/GoogleUtilities.git image_editor_common: :path: ".symlinks/plugins/image_editor_common/ios" image_picker_ios: @@ -970,15 +1029,17 @@ EXTERNAL SOURCES: package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: - :path: ".symlinks/plugins/path_provider_foundation/darwin" + :path: ".symlinks/plugins/path_provider_foundation/ios" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + screen_protector: + :path: ".symlinks/plugins/screen_protector/ios" sentry_flutter: :path: ".symlinks/plugins/sentry_flutter/ios" share_plus: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + :path: ".symlinks/plugins/shared_preferences_foundation/ios" sqflite: :path: ".symlinks/plugins/sqflite/ios" url_launcher_ios: @@ -986,60 +1047,70 @@ EXTERNAL SOURCES: video_player_avfoundation: :path: ".symlinks/plugins/video_player_avfoundation/ios" +CHECKOUT OPTIONS: + GoogleUtilities: + :commit: 26c898aed8bed13b8a63057ee26500abbbcb8d55 + :git: https://github.com/google/GoogleUtilities.git + SPEC CHECKSUMS: abseil: ebe5b5529fb05d93a8bdb7951607be08b7fa71bc - awesome_notifications: d63d9a25f126860f9a600850d99772237895b3ba + awesome_notifications: db394d2e061e4583ba0f738ddea611e3986cc3fb awesome_notifications_core: d02eed89738fa362d56cbd372850e9adcd2c6bef - awesome_notifications_fcm: 7e2d7ab4ca1826fe3a9a5ca96771ace73e05db48 + awesome_notifications_fcm: 554a9088f81a91dbe8a318abee6b48f43e4a9417 background_fetch: 896944864b038d2837fc750d470e9841e1e6a363 BoringSSL-GRPC: 3175b25143e648463a56daeaaa499c6cb86dad33 cloud_firestore: e0da2eedba479416c908433ac8879fdd81f61157 Firebase: 0219acf760880eeec8ce479895bd7767466d9f81 firebase_analytics: e8e294333de66e5429d4aac365966281b4dbfb7d - firebase_core: dee76ded6c693fdb38b8ea39aef7129e32e587a3 + firebase_core: 27bc73423698d0960be189ccf29bdc2d373f2746 + firebase_crashlytics: 1b3768f2e118b0281ad30f2931369e0d1921789c firebase_in_app_messaging: 2b36a1746f4fefbd6f578a2f6659358a0d3f2c94 firebase_remote_config: e5f1ed5b29191424280b7b249228f0ed64bc90ee - FirebaseABTesting: 03f0a8b88cf618350527f2c6a2234e29b9c65064 + FirebaseABTesting: aec61ed9a34d85a95e2013a3fdf051426a2419df FirebaseAnalytics: f8133442ee6f8512e28ff19e62ce15398bfaeace FirebaseCore: e317665b9d744727a97e623edbbed009320afdd7 + FirebaseCoreExtension: cb88851781a24e031d1b58e0bd01eb1f46b044b5 FirebaseCoreInternal: 26233f705cc4531236818a07ac84d20c333e505a + FirebaseCrashlytics: 35fdd1a433b31e28adcf5c8933f4c526691a1e0b FirebaseFirestore: 3963a6edd1c84b4748dab3e2c62624a29d03eca1 FirebaseInAppMessaging: d04732fe9c37c3d026d66435abba60120087a7f5 - FirebaseInstallations: b822f91a61f7d1ba763e5ccc9d4f2e6f2ed3b3ee + FirebaseInstallations: 42d6ead4605d6eafb3b6683674e80e18eb6f2c35 FirebaseMessaging: ac9062bcc35ed56e15a0241d8fd317022499baf8 FirebaseRemoteConfig: d5de62211e2eaa2152d8ee85a23c301b70887a74 + FirebaseSessions: f06853e30f99fe42aa511014d7ee6c8c319f08a3 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 - flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_uxcam: 49ca783e481233911475724eb1366064183fffc1 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a GoogleAppMeasurement: fe17c92a32207dd5cdd4e8d742767f2da74857f6 - GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2 - GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084 + GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a + GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152 "gRPC-C++": 9675f953ace2b3de7c506039d77be1f2e77a8db2 gRPC-Core: 943e491cb0d45598b0b0eb9e910c88080369290b image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43 image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d - IosAwnCore: 6494e0e174d49f04f513e8a002187be226889a37 - IosAwnFcmDist: 47578dd46472304b20f1965ad12cc893c877e57c - leveldb-library: f03246171cce0484482ec291f88b6d563699ee06 + IosAwnCore: ed1b2b6d84962a758354dbacd9ce525c72ce28a9 + IosAwnFcmDist: 0ac4da8f9ef8f2db9e5ec5d0c10c8db793ce447d + leveldb-library: 06a69cc7582d64b29424a63e085e683cc188230a Libuv-gRPC: 55e51798e14ef436ad9bc45d12d43b77b49df378 nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 - PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 - Sentry: 39d57e691e311bdb73bc1ab5bbebbd6bc890050d - sentry_flutter: b2feefdad5b0f06602347172bc7257e8e9da5562 - SentryPrivate: 48712023cdfd523735c2edb6b06bedf26c4730a3 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 + screen_protector: 6f92086bd2f2f4b54f54913289b9d1310610140b + ScreenProtectorKit: 83a6281b02c7a5902ee6eac4f5045f674e902ae4 + Sentry: 51b056d96914a741f63eca774d118678b1eb05a1 + sentry_flutter: e8397d13e297a5d4b6be8a752e33140b21c5cc97 share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 - UXCam: 7f200dd8f0829ae92a2ef82cadbb35dadff6108d + UXCam: 73a2718c84c547012c5bd47959179d900b1948c2 video_player_avfoundation: 81e49bb3d9fb63dccf9fa0f6d877dc3ddbeac126 -PODFILE CHECKSUM: 816280d49dfc997733647f095bf1e4c52c291937 +PODFILE CHECKSUM: 4d4f03dca85c6d30067635d550a818b62d393f0e -COCOAPODS: 1.13.0 +COCOAPODS: 1.15.2 diff --git a/ios/PrivacyInfo.xcprivacy b/ios/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..e17b525 --- /dev/null +++ b/ios/PrivacyInfo.xcprivacy @@ -0,0 +1,33 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + 7D9E.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + + diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index fc3827b..bb1e76d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 79C1453E2BBDF10B0064A7E0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 79FBEAF12BBDC94D0075EACE /* PrivacyInfo.xcprivacy */; }; + 79FBEAF22BBDC94D0075EACE /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 79FBEAF12BBDC94D0075EACE /* PrivacyInfo.xcprivacy */; }; 8F4ED3CFA3A6AA57DAA4905A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6121D4E2A474C9DE828CDCAE /* Pods_Runner.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -72,6 +74,7 @@ 6DE503BB399A91B6C5520D76 /* Pods-Runner.release-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release-dev.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release-dev.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 79FBEAF12BBDC94D0075EACE /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7FE2002C76C7F77E5A50737A /* Pods-MiUtemNotificationServiceExtension.debug-dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MiUtemNotificationServiceExtension.debug-dev.xcconfig"; path = "Target Support Files/Pods-MiUtemNotificationServiceExtension/Pods-MiUtemNotificationServiceExtension.debug-dev.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -159,6 +162,7 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + 79FBEAF12BBDC94D0075EACE /* PrivacyInfo.xcprivacy */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, EBE5B6BC29D4D890005924AB /* MiUtemNotificationServiceExtension */, @@ -256,7 +260,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1420; - LastUpgradeCheck = 1430; + LastUpgradeCheck = 1300; ORGANIZATIONNAME = ""; TargetAttributes = { 97C146ED1CF9000F007C117D = { @@ -298,6 +302,7 @@ EBCDF3152A08916400C04BA3 /* prod.xcconfig in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + 79FBEAF22BBDC94D0075EACE /* PrivacyInfo.xcprivacy in Resources */, EBBC93162A9CF30F00452092 /* GoogleService-Info.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -306,6 +311,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 79C1453E2BBDF10B0064A7E0 /* PrivacyInfo.xcprivacy in Resources */, EBCDF3192A0891B800C04BA3 /* dev.xcconfig in Resources */, EBCDF3162A08916400C04BA3 /* prod.xcconfig in Resources */, ); @@ -848,7 +854,7 @@ INFOPLIST_FILE = MiUtemNotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = MiUtemNotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -980,7 +986,7 @@ INFOPLIST_FILE = MiUtemNotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = MiUtemNotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1104,7 +1110,7 @@ INFOPLIST_FILE = MiUtemNotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = MiUtemNotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1142,7 +1148,7 @@ INFOPLIST_FILE = MiUtemNotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = MiUtemNotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1185,7 +1191,7 @@ INFOPLIST_FILE = MiUtemNotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = MiUtemNotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1225,7 +1231,7 @@ INFOPLIST_FILE = MiUtemNotificationServiceExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = MiUtemNotificationServiceExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme index 77d0e8a..70cb7d0 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme @@ -50,6 +50,13 @@ ReferencedContainer = "container:Runner.xcodeproj"> + + + + ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS NSAppTransportSecurity @@ -37,6 +39,8 @@ Mi UTEM quiere acceder a tu cámara para que puedas subir contenido NSPhotoLibraryUsageDescription Mi UTEM quiere acceder a tus fotos para que puedas subir contenido + NSUserNotificationsUsageDescription + Queremos avisarte cuando existan cambios en tus notas, o sobre otras novedades. UIBackgroundModes fetch diff --git a/ios/Runner/Info-prod.plist b/ios/Runner/Info-prod.plist index 37909b4..9ee5183 100644 --- a/ios/Runner/Info-prod.plist +++ b/ios/Runner/Info-prod.plist @@ -26,6 +26,8 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS NSAppTransportSecurity @@ -37,6 +39,8 @@ Mi UTEM quiere acceder a tu cámara para que puedas subir contenido NSPhotoLibraryUsageDescription Mi UTEM quiere acceder a tus fotos para que puedas subir contenido + NSUserNotificationsUsageDescription + Queremos avisarte cuando existan cambios en tus notas, o sobre otras novedades. UIBackgroundModes fetch diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index 903def2..a8b7773 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -4,5 +4,9 @@ aps-environment development + com.apple.developer.associated-domains + + webcredentials:mi.utem.cl + diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index add9d1b..2e19a34 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -62,6 +62,7 @@ lane :ios_build_and_upload do |options| app_identifier: [ENV["APP_IDENTIFIER_IOS"], ENV["APP_IDENTIFIER_IOS"] + ".MiUtemNotificationServiceExtension"], type: "appstore", git_url: ENV["MATCH_REPO_GIT_URL"], + git_basic_authorization: ENV["MATCH_GIT_BASIC_AUTHORIZATION"], readonly: is_ci, keychain_name: ENV["KEYCHAIN_NAME"], keychain_password: ENV["KEYCHAIN_PASSWORD"], @@ -83,19 +84,34 @@ lane :ios_build_and_upload do |options| end lane :ios_beta_upload do |options| + api_key = app_store_connect_api_key( + key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"], + issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"], + key_filepath: "./ios/fastlane/AuthKey.p8", + in_house: true, # Esto debe ser true porque estamos usando match + ) + changelog = options[:changelog] is_ci = options[:is_ci] upload_to_testflight( + api_key: api_key, changelog: changelog, skip_waiting_for_build_processing: is_ci ) end lane :ios_release_upload do |options| + api_key = app_store_connect_api_key( + key_id: ENV["APP_STORE_CONNECT_API_KEY_ID"], + issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"], + key_filepath: "./ios/fastlane/AuthKey.p8", + in_house: true, # Esto debe ser true porque estamos usando match + ) changelog = options[:changelog] upload_to_app_store( + api_key: api_key, release_notes: changelog ) end diff --git a/lib/config/constants.dart b/lib/config/constants.dart index 08a447a..9d4204a 100644 --- a/lib/config/constants.dart +++ b/lib/config/constants.dart @@ -1,6 +1,6 @@ -class Constants { - static const String sentryDsn = - 'https://0af59b2ad2b44f4e8c9cad4ea8d5f32e@o507661.ingest.sentry.io/5599080'; - static const String uxCamDevKey = '0y6p88obpgiug1g'; - static const String uxCamProdKey = 'fxkjj5ulr7vb4yf'; -} +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +final apiUrl = bool.fromEnvironment('dart.vm.product') ? 'https://api.exdev.cl' : (dotenv.env['MI_UTEM_API_DEBUG'] ?? 'https://api.exdev.cl'); +const String sentryDsn = 'https://c03edae5839c62f95de91c1cbabb65d7@o4506938204553216.ingest.us.sentry.io/4506938205470720'; +const String uxCamDevKey = '0y6p88obpgiug1g'; +const String uxCamProdKey = 'fxkjj5ulr7vb4yf'; diff --git a/lib/config/logger.dart b/lib/config/logger.dart index 55058ea..13ad4a3 100644 --- a/lib/config/logger.dart +++ b/lib/config/logger.dart @@ -1,3 +1,5 @@ import 'package:logger/logger.dart'; -final logger = Logger(); +final logger = Logger( + printer: PrettyPrinter(), +); diff --git a/lib/config/router.dart b/lib/config/router.dart deleted file mode 100644 index 2826c2b..0000000 --- a/lib/config/router.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/route_manager.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/screens/asignatura_detalle_screen.dart'; -import 'package:mi_utem/screens/asignaturas_lista_screen.dart'; -import 'package:mi_utem/controllers/asignaturas_controller.dart'; -import 'package:mi_utem/controllers/qr_passes_controller.dart'; -import 'package:mi_utem/screens/calculadora_notas_screen.dart'; -import 'package:mi_utem/screens/credencial_screen.dart'; -import 'package:mi_utem/screens/horario/horario_screen.dart'; -import 'package:mi_utem/screens/login_screen/login_screen.dart'; -import 'package:mi_utem/screens/main_screen.dart'; -import 'package:mi_utem/screens/permiso_covid_screen.dart'; -import 'package:mi_utem/screens/splash_screen.dart'; -import 'package:mi_utem/screens/usuario_screen.dart'; -import 'package:mi_utem/services/auth_service.dart'; -import 'package:mi_utem/services/perfil_service.dart'; -import 'package:mi_utem/widgets/acerca_screen.dart'; - -final _loginPage = GetPage( - name: Routes.login, - page: () => LoginScreen(), - middlewares: [OnlyNoAuthMiddleware()], -); - -final _homePage = GetPage( - name: Routes.home, - bindings: [QrPassesBinding()], - page: () { - final usuario = PerfilService.getLocalUsuario(); - - return MainScreen(usuario: usuario); - }, - middlewares: [OnlyAuthMiddleware()], -); - -final pages = [ - GetPage( - name: Routes.splash, - page: () => SplashScreen(), - ), - GetPage( - name: Routes.about, - page: () => AcercaScreen(), - ), - _loginPage, - _homePage, - GetPage( - name: Routes.perfil, - page: () => UsuarioScreen(), - middlewares: [OnlyAuthMiddleware()], - ), - GetPage( - name: Routes.credencial, - page: () => CredencialScreen(), - middlewares: [OnlyAuthMiddleware()], - ), - GetPage( - name: Routes.calculadoraNotas, - page: () => CalculadoraNotasScreen(), - middlewares: [OnlyAuthMiddleware()], - ), - GetPage( - name: Routes.horario, - page: () => HorarioScreen(), - binding: HorarioBinding(), - middlewares: [OnlyAuthMiddleware()], - ), - GetPage( - name: Routes.asignaturas, - page: () => AsignaturasListaScreen(), - middlewares: [OnlyAuthMiddleware()], - binding: AsignaturasBinding(), - ), - GetPage( - name: '${Routes.asignatura}/:asignaturaId', - page: () => AsignaturaDetalleScreen(), - ), - GetPage( - name: Routes.pass, - page: () => PermisoCovidScreen(), - middlewares: [OnlyAuthMiddleware()], - ), -]; - -class OnlyAuthMiddleware extends GetMiddleware { - @override - RouteSettings? redirect(String? page) { - final isLoggedIn = AuthService.isLoggedIn(); - if (!isLoggedIn) { - return const RouteSettings(name: Routes.login); - } - return null; - } -} - -class OnlyNoAuthMiddleware extends GetMiddleware { - @override - RouteSettings? redirect(String? page) { - final isLoggedIn = AuthService.isLoggedIn(); - if (isLoggedIn) { - return const RouteSettings(name: Routes.home); - } - return null; - } -} diff --git a/lib/config/routes.dart b/lib/config/routes.dart deleted file mode 100644 index 1884123..0000000 --- a/lib/config/routes.dart +++ /dev/null @@ -1,17 +0,0 @@ -class Routes { - static const splash = '/splash'; - static const login = '/login'; - static const home = '/'; - static const about = '/about'; - static const horario = '/horario'; - static const asignaturas = '/asignaturas'; - static const perfil = '/perfil'; - static const credencial = '/credencial'; - static const calculadoraNotas = '/calculadora-notas'; - static const imageView = '/image-view'; - static const asignatura = '/asignatura'; - - static const passBase = '/pass'; - static const passParameter = 'passId'; - static const pass = '$passBase/:$passParameter'; -} diff --git a/lib/config/secure_storage.dart b/lib/config/secure_storage.dart new file mode 100644 index 0000000..5366ad9 --- /dev/null +++ b/lib/config/secure_storage.dart @@ -0,0 +1,3 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +final secureStorage = FlutterSecureStorage(); \ No newline at end of file diff --git a/lib/controllers/asignatura_controller.dart b/lib/controllers/asignatura_controller.dart deleted file mode 100644 index 544e732..0000000 --- a/lib/controllers/asignatura_controller.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:get/get.dart'; -import 'package:mi_utem/controllers/carreras_controller.dart'; -import 'package:mi_utem/models/asignatura.dart'; -import 'package:mi_utem/models/carrera.dart'; -import 'package:mi_utem/models/grades.dart'; -import 'package:mi_utem/services/grades_service.dart'; - -class AsignaturaController extends GetxController with StateMixin { - late final String asignaturaId; - late final Asignatura? _initialAsignatura; - - Carrera? _selectedCarrera; - - AsignaturaController(this.asignaturaId, {Asignatura? asignatura}) { - if (asignatura != null) { - _initialAsignatura = asignatura; - } - } - - @override - void onInit() { - _selectedCarrera = Get.find().selectedCarrera.value; - if (_selectedCarrera != null) { - getAsignaturaDetail(Get.find().selectedCarrera.value); - } - - ever( - Get.find().selectedCarrera, - (carrera) { - _selectedCarrera = carrera; - getAsignaturaDetail(carrera, forceRefresh: true); - }, - ); - super.onInit(); - } - - Future refreshData() async { - await getAsignaturaDetail(_selectedCarrera, forceRefresh: true); - } - - Future getAsignaturaDetail(Carrera? carrera, - {bool forceRefresh = false}) async { - final carreraId = carrera?.id; - if (carreraId != null) { - change(null, status: RxStatus.loading()); - try { - Grades grades = await GradesService.getGrades( - carreraId, - asignaturaId, - forceRefresh: forceRefresh, - ); - - Asignatura asignatura = _initialAsignatura!; - asignatura.grades = grades; - - change(asignatura, status: RxStatus.success()); - } catch (e) { - change(null, status: RxStatus.error(e.toString())); - } - } - } -} diff --git a/lib/controllers/asignaturas_controller.dart b/lib/controllers/asignaturas_controller.dart deleted file mode 100644 index a8090d1..0000000 --- a/lib/controllers/asignaturas_controller.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:get/get.dart'; -import 'package:mi_utem/controllers/asignatura_controller.dart'; -import 'package:mi_utem/controllers/carreras_controller.dart'; -import 'package:mi_utem/models/asignatura.dart'; -import 'package:mi_utem/models/carrera.dart'; -import 'package:mi_utem/services/asignaturas_service.dart'; - -class AsignaturasController extends GetxController - with StateMixin> { - @override - void onInit() { - change(null, status: RxStatus.loading()); - - if (Get.find().selectedCarrera.value != null) { - getAsignaturas(Get.find().selectedCarrera.value); - } - - ever( - Get.find().selectedCarrera, - (carrera) => getAsignaturas(carrera, forceRefresh: true), - ); - super.onInit(); - } - - void getAsignaturas( - Carrera? carrera, { - bool forceRefresh = false, - }) async { - change(null, status: RxStatus.loading()); - - final carreraId = carrera?.id; - - if (carreraId == null) { - change(null, status: RxStatus.error("No hay carrera seleccionada")); - return; - } - - List response = await AsignaturasService.getAsignaturas( - carreraId, - forceRefresh: forceRefresh, - ); - - change(response, status: RxStatus.success()); - - for (var asignatura in response) { - if (asignatura.id != null) { - Get.put( - AsignaturaController(asignatura.id!, asignatura: asignatura), - tag: asignatura.id, - permanent: true, - ); - } - } - } - - void refreshAsignaturas() { - final carrera = Get.find().selectedCarrera.value; - getAsignaturas(carrera, forceRefresh: true); - } -} - -class AsignaturasBinding extends Bindings { - @override - void dependencies() { - Get.put(AsignaturasController(), permanent: true); - } -} diff --git a/lib/controllers/calculator_controller.dart b/lib/controllers/calculator_controller.dart index c042466..a415542 100644 --- a/lib/controllers/calculator_controller.dart +++ b/lib/controllers/calculator_controller.dart @@ -1,9 +1,9 @@ -import 'package:flutter_masked_text/flutter_masked_text.dart'; +import 'package:extended_masked_text/extended_masked_text.dart'; import 'package:get/get.dart'; -import 'package:mi_utem/models/evaluacion.dart'; -import 'package:mi_utem/models/grades.dart'; +import 'package:mi_utem/models/evaluacion/evaluacion.dart'; +import 'package:mi_utem/models/evaluacion/grades.dart'; -class CalculatorController extends GetxController { +class CalculatorController { static const maxPercentage = 100; static const maxGrade = 7; static const minimumGradeForExam = 2.95; @@ -18,8 +18,6 @@ class CalculatorController extends GetxController { final examGradeTextFieldController = MaskedTextController(mask: "0.0"); final freeEditable = false.obs; - static CalculatorController get to => Get.find(); - double? get calculatedFinalGrade { if (calculatedPresentationGrade != null) { if (examGrade.value != null) { @@ -127,45 +125,51 @@ class CalculatorController extends GetxController { double? get suggestedGrade { if (hasMissingPartialGrade && percentageWithoutGrade > 0) { final weightOfMissingGrades = percentageWithoutGrade / maxPercentage; - final requiredGradeValue = - passingGrade - (suggestedPresentationGrade ?? 0); - final missingGradesValue = requiredGradeValue / weightOfMissingGrades; - return missingGradesValue; + final requiredGradeValue = passingGrade - (suggestedPresentationGrade ?? 0); + return requiredGradeValue / weightOfMissingGrades; } return null; } - void makeEditable() { - freeEditable.value = true; - } + bool get hasGrades => partialGrades.isNotEmpty; + + void makeEditable() => freeEditable.value = true; + + void makeNonEditable() => freeEditable.value = false; - void makeNonEditable() { - freeEditable.value = false; + void clearGrades() { + partialGrades.clear(); + percentageTextFieldControllers.clear(); + gradeTextFieldControllers.clear(); + clearExamGrade(); } - void loadGrades(Grades grades) { + void updateWithGrades(Grades? grades) { partialGrades.clear(); percentageTextFieldControllers.clear(); gradeTextFieldControllers.clear(); - for (var evaluacion in grades.notasParciales) { - final partialGrade = IEvaluacion.fromRemote(evaluacion); - addGrade(partialGrade); + if(grades == null) { + return; + } + + for(final grade in grades.notasParciales) { + addGrade(IEvaluacion.fromRemote(grade)); } setExamGrade(grades.notaExamen); } - void changeGradeAt(int index, IEvaluacion changedGrade) { + void updateGradeAt(int index, IEvaluacion updatedGrade) { final grade = partialGrades[index]; - if (grade.editable || freeEditable.value) { - partialGrades[index] = changedGrade; + if(!(grade.editable || freeEditable.value)) { + return; + } - if (hasMissingPartialGrade) { - clearExamGrade(); - } - } else { - throw Exception("No se puede editar una nota que está asignada"); + partialGrades[index] = updatedGrade; + + if (hasMissingPartialGrade) { + clearExamGrade(); } } @@ -174,26 +178,17 @@ class CalculatorController extends GetxController { examGradeTextFieldController.text = ""; } - void setExamGrade(num? grade) { + void setExamGrade(num? grade, { bool updateTextController = true }) { examGrade.value = grade?.toDouble(); - examGradeTextFieldController.text = - grade?.toDouble().toStringAsFixed(1) ?? ""; + if(updateTextController) { + examGradeTextFieldController.updateText(grade?.toDouble().toStringAsFixed(1) ?? ""); + } } void addGrade(IEvaluacion grade) { partialGrades.add(grade); - percentageTextFieldControllers.add( - MaskedTextController( - mask: "000", - text: grade.porcentaje?.toDouble().toStringAsFixed(0) ?? "", - ), - ); - gradeTextFieldControllers.add( - MaskedTextController( - mask: "0.0", - text: grade.nota?.toDouble().toStringAsFixed(1) ?? "", - ), - ); + percentageTextFieldControllers.add(MaskedTextController(mask: "000", text: grade.porcentaje?.toStringAsFixed(0) ?? "")); + gradeTextFieldControllers.add(MaskedTextController(mask: "0.0", text: grade.nota?.toStringAsFixed(1) ?? "")); } void removeGradeAt(int index) { @@ -206,4 +201,4 @@ class CalculatorController extends GetxController { throw Exception("No se puede eliminar una nota que está asignada"); } } -} +} \ No newline at end of file diff --git a/lib/controllers/carreras_controller.dart b/lib/controllers/carreras_controller.dart deleted file mode 100644 index 20eb963..0000000 --- a/lib/controllers/carreras_controller.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:get/get.dart'; -import 'package:mi_utem/models/carrera.dart'; -import 'package:mi_utem/services/analytics_service.dart'; -import 'package:mi_utem/services/carreras_service.dart'; - -class CarrerasController extends GetxController { - final carreras = [].obs; - final selectedCarrera = Rxn(); - - static CarrerasController get to => Get.find(); - - @override - void onInit() { - getCarreras(); - - super.onInit(); - } - - void getCarreras() async { - final carreras = await CarreraService.getCarreras(forceRefresh: true); - - this.carreras.value = carreras; - _autoSelectCarreraActiva(carreras); - } - - void _autoSelectCarreraActiva(List carreras) { - final estados = ["Regular", "Causal de Eliminacion"] - .reversed - .map((e) => e.toLowerCase()) - .toList(); - - carreras.sort( - (a, b) => estados.indexOf(b.estado!.toLowerCase()).compareTo( - estados.indexOf(a.estado!.toLowerCase()), - ), - ); - - Carrera activa = carreras.first; - - AnalyticsService.setCarreraToUser(activa); - - changeSelectedCarrera(activa); - } - - void changeSelectedCarrera(Carrera carrera) { - selectedCarrera.value = carrera; - } -} diff --git a/lib/controllers/grades_changes_controller.dart b/lib/controllers/grades_changes_controller.dart deleted file mode 100644 index 0be8fb7..0000000 --- a/lib/controllers/grades_changes_controller.dart +++ /dev/null @@ -1,249 +0,0 @@ -import 'dart:developer'; - -import 'package:get_storage/get_storage.dart'; -import 'package:mi_utem/controllers/carreras_controller.dart'; -import 'package:mi_utem/models/asignatura.dart'; -import 'package:mi_utem/models/grades.dart'; -import 'package:mi_utem/services/asignaturas_service.dart'; -import 'package:mi_utem/services/auth_service.dart'; -import 'package:mi_utem/services/grades_service.dart'; -import 'package:mi_utem/services/notification_service.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; - -class GradesChangesController { - static const savedGradesPrefix = 'savedGrades_'; - static const suscribedAsignaturasPrefix = 'suscribedAsignaturas_'; - - static final GetStorage box = GetStorage(); - - static Future saveGrades(String asignaturaId, Grades grades) async { - final jsonGrades = grades.toJson(); - jsonGrades['lastUpdate'] = DateTime.now().toIso8601String(); - - log('Saving grades for $asignaturaId: $jsonGrades'); - - return box.write('$savedGradesPrefix$asignaturaId', jsonGrades); - } - - static GradeChangeType _getGradeValueChangeType( - Grades oldGrades, - Grades updatedGrades, - ) { - final oldGradesLength = oldGrades.notasParciales.length; - final updatedGradesLength = updatedGrades.notasParciales.length; - - if (oldGradesLength == updatedGradesLength) { - GradeChangeType? currentChange; - - for (int i = 0; i < oldGradesLength; i++) { - final oldValue = oldGrades.notasParciales[i]; - final updatedValue = updatedGrades.notasParciales[i]; - - if (oldValue.nota != updatedValue.nota) { - if (oldValue.nota == null && updatedValue.nota != null) { - Sentry.configureScope( - (scope) => scope.setExtra('newGrade', updatedValue.nota), - ); - currentChange = GradeChangeType.gradeSetted; - } else if (oldValue.nota != null && updatedValue.nota == null) { - currentChange = currentChange ?? GradeChangeType.gradeDeleted; - } else { - currentChange = currentChange ?? GradeChangeType.gradeUpdated; - } - } - } - - if (currentChange != null) { - return currentChange; - } - } else { - Sentry.captureMessage( - 'Asignatura $oldGrades.id has a different number of weighters in _getGradeValueChangeType function', - level: SentryLevel.warning, - ); - } - return GradeChangeType.noChange; - } - - static bool _hasAWeighterDiferrence( - Grades oldGrades, - Grades updatedGrades, - ) { - final oldGradesLength = oldGrades.notasParciales.length; - final updatedGradesLength = updatedGrades.notasParciales.length; - - if (oldGradesLength == updatedGradesLength) { - for (int i = 0; i < oldGradesLength; i++) { - final oldWeighter = oldGrades.notasParciales[i]; - final updatedWeighter = updatedGrades.notasParciales[i]; - - if (oldWeighter.porcentaje != updatedWeighter.porcentaje) { - return true; - } - } - } else { - Sentry.captureMessage( - 'Asignatura $oldGrades.id has a different number of weighters in _hasAWeighterDiferrence function', - level: SentryLevel.warning, - ); - } - return false; - } - - static bool _hasAGradeWithValue(Grades asignatura) { - return asignatura.notasParciales.any((element) => element.nota != null); - } - - static GradeChangeType compareGrades( - String asignaturaId, - Grades updatedGrades, - ) { - final oldGradesJson = box.read('$savedGradesPrefix$asignaturaId'); - - if (oldGradesJson != null) { - final oldGrades = Grades.fromJson(oldGradesJson); - - log(oldGrades.toString()); - - final oldGradesLength = oldGrades.notasParciales.length; - final updatedGradesLength = updatedGrades.notasParciales.length; - - if (oldGradesLength == 0) { - if (updatedGradesLength == 0) { - return GradeChangeType.noChange; - } else { - if (_hasAGradeWithValue(updatedGrades)) { - return GradeChangeType.gradeSetted; - } else { - return GradeChangeType.weightersSetted; - } - } - } else { - if (updatedGradesLength == 0) { - return GradeChangeType.weightersDeleted; - } else { - if (oldGradesLength != updatedGradesLength) { - return GradeChangeType.weightersUpdated; - } else { - if (_hasAWeighterDiferrence(oldGrades, updatedGrades)) { - return GradeChangeType.weightersUpdated; - } else { - return _getGradeValueChangeType(oldGrades, updatedGrades); - } - } - } - } - } else { - log('compareGrades oldGradesJson was null'); - } - - return GradeChangeType.noChange; - } - - static Future> checkIfGradesHasChange() async { - final isLogged = AuthService.isLoggedIn(); - - if (isLogged) { - final carrera = CarrerasController.to.selectedCarrera.value; - final carreraId = carrera?.id; - - if (carreraId != null) { - final suscribedAsignaturasJson = - box.read('$suscribedAsignaturasPrefix$carreraId'); - List? suscribedAsignaturas; - - if (suscribedAsignaturasJson == null) { - final asignaturas = - await AsignaturasService.getAsignaturas(carreraId); - final asignaturasJson = asignaturas.map((e) => e.toJson()).toList(); - suscribedAsignaturas = asignaturas; - box.write('$suscribedAsignaturasPrefix$carreraId', asignaturasJson); - } else { - suscribedAsignaturas = - Asignatura.fromJsonList(suscribedAsignaturasJson); - } - - for (Asignatura? asignatura in suscribedAsignaturas) { - final asignaturaId = asignatura?.id; - if (asignatura != null && asignaturaId != null) { - final updatedGrades = await GradesService.getGrades( - carreraId, - asignaturaId, - forceRefresh: true, - saveGrades: false, - ); - - final changeType = compareGrades( - asignaturaId, - updatedGrades, - ); - - await saveGrades(asignaturaId, updatedGrades); - - _notificateChange(asignatura, changeType); - } - } - } - } - - return {}; - } - - static void _notificateChange(Asignatura asignatura, GradeChangeType change) { - final asignaturaName = asignatura.nombre ?? asignatura.codigo; - - String? title; - String? body; - - switch (change) { - case GradeChangeType.gradeSetted: - title = 'Tienes una nueva nota'; - body = '$asignaturaName: se ha agregado una nota'; - break; - case GradeChangeType.gradeUpdated: - title = 'Una nota ha cambiado'; - body = '$asignaturaName: se ha actualizado una nota'; - break; - case GradeChangeType.gradeDeleted: - title = 'Una nota se ha borrado'; - body = '$asignaturaName: se ha eliminado una nota'; - break; - default: - break; - } - - if (title != null && body != null) { - Sentry.captureMessage( - 'Asignatura has changed and notificated', - level: SentryLevel.debug, - withScope: (scope) { - scope.setTag('asignaturaId', asignatura.id.toString()); - scope.setTag('asignaturaCodigo', asignatura.codigo.toString()); - scope.setTag('change', change.toString()); - }, - ); - - NotificationService.showGradeChangeNotification(title, body, asignatura); - } else if (change != GradeChangeType.noChange) { - Sentry.captureMessage( - 'Asignatura has changed but not notificated', - level: SentryLevel.debug, - withScope: (scope) { - scope.setTag('asignaturaId', asignatura.id.toString()); - scope.setTag('asignaturaCodigo', asignatura.codigo.toString()); - scope.setTag('change', change.toString()); - }, - ); - } - } -} - -enum GradeChangeType { - weightersSetted, - weightersUpdated, - weightersDeleted, - gradeSetted, - gradeUpdated, - gradeDeleted, - noChange -} diff --git a/lib/controllers/horario_controller.dart b/lib/controllers/horario_controller.dart index aac0bb3..5184a58 100644 --- a/lib/controllers/horario_controller.dart +++ b/lib/controllers/horario_controller.dart @@ -1,138 +1,175 @@ -import 'dart:developer'; - import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; -import 'package:mi_utem/controllers/carreras_controller.dart'; -import 'package:mi_utem/models/asignatura.dart'; -import 'package:mi_utem/models/carrera.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; import 'package:mi_utem/models/horario.dart'; +import 'package:mi_utem/repositories/horario_repository.dart'; import 'package:mi_utem/screens/horario/widgets/horario_main_scroller.dart'; -import 'package:mi_utem/services/horarios_service.dart'; +import 'package:mi_utem/services/carreras_service.dart'; import 'package:mi_utem/services/remote_config/remote_config.dart'; +import 'package:mi_utem/utils/utils.dart'; import 'package:vector_math/vector_math_64.dart' as vector; -class HorarioController extends GetxController { - static const daysCount = 6; - static const periodsCount = 9; +class HorarioController { + final _storage = GetStorage(); + final _randomColors = Colors.primaries.toList()..shuffle(); + final _now = DateTime.now(); + + num daysCount = 6; + + num periodsCount = 9; + + String startTime = "7:55"; + + Duration periodDuration = Duration(minutes: 90); + + Duration periodGap = Duration(minutes: 5); + + List usedColors = []; - static const startTime = "7:55"; - static const periodDuration = Duration(minutes: 90); - static const periodGap = Duration(minutes: 5); + RxDouble zoom = 0.5.obs; - static final GetStorage _box = GetStorage(); - static final List _randomColors = Colors.primaries.toList()..shuffle(); + RxBool indicatorIsOpen = false.obs; - final horario = Rxn(null); - final loadingHorario = false.obs; - final usedColors = []; - final zoom = 0.5.obs; - final indicatorIsOpen = false.obs; - final isCenteredInCurrentPeriodAndDay = false.obs; + RxBool isCenteredInCurrentPeriodAndDay = false.obs; - final blockContentController = TransformationController(); - final daysHeaderController = TransformationController(); - final periodHeaderController = TransformationController(); - final cornerController = TransformationController(); + TransformationController blockContentController = TransformationController(); - static HorarioController get to => Get.find(); + TransformationController daysHeaderController = TransformationController(); + + TransformationController periodHeaderController = TransformationController(); + + TransformationController cornerController = TransformationController(); + + Function? _onUpdate; List get unusedColors { - List availableColors = [..._randomColors]; - availableColors.retainWhere((Color color) => !usedColors.contains(color)); - if (availableColors.length == 0) { - return [..._randomColors]; - } - return availableColors; + List availableColors = [..._randomColors].where((Color color) => !usedColors.contains(color)).toList(); + return availableColors.isEmpty ? [..._randomColors] : availableColors; } - DateTime _now = DateTime.now(); - - double get minutesFromStart { - final now = _now; - final startTimeParts = startTime.split(":"); - final startDateTime = DateTime( - now.year, - now.month, - now.day, - int.parse(startTimeParts[0]), - int.parse(startTimeParts[1]), - ); - return now.difference(startDateTime).inMinutes.toDouble(); - } + double get minutesFromStart => _now.difference(DateTime(_now.year, _now.month, _now.day, int.parse(startTime.split(":")[0]), int.parse(startTime.split(":")[1]))).inMinutes.toDouble(); - int? get indexOfCurrentDayStartingAtMonday { - final now = _now; - final day = now.weekday; - return day > daysCount ? null : day - 1; - } + int? get indexOfCurrentDayStartingAtMonday => _now.weekday > daysCount ? null : _now.weekday - 1; int? get indexOfCurrentPeriod { - final minutes = minutesFromStart; - final periodBlockDuration = - periodDuration.inMinutes + (periodGap.inMinutes * 2); + final periodBlockDuration = periodDuration.inMinutes + (periodGap.inMinutes * 2); - final period = minutes ~/ periodBlockDuration; - final minutesModule = minutes % periodBlockDuration; + final minutesModule = minutesFromStart % periodBlockDuration; - if (minutesModule >= periodGap.inMinutes && - minutesModule <= (periodBlockDuration - periodGap.inMinutes)) { - return period; + if (minutesModule >= periodGap.inMinutes && minutesModule <= (periodBlockDuration - periodGap.inMinutes)) { + return (minutesFromStart ~/ periodBlockDuration); // Periodo actual } return null; } - @override - onInit() { - if (Get.find().selectedCarrera.value != null) { - getHorarioData(Get.find().selectedCarrera.value); + void init(BuildContext context) { + zoom.value = RemoteConfigService.horarioZoom; + moveViewportToCurrentPeriodAndDay(context); + setZoom(zoom.value); + + _setScrollControllerListeners(); + } + + Future getHorario({ bool forceRefresh = false }) async { + final carreraId = (await Get.find().getCarreras(forceRefresh: forceRefresh))?.id; + if(carreraId == null) { + return null; } - ever( - Get.find().selectedCarrera, - (carrera) => getHorarioData(carrera), - ); - _init(); - super.onInit(); + final horario = await Get.find().getHorario(carreraId, forceRefresh: forceRefresh); + if(horario != null) _setRandomColorsByHorario(horario); + return horario; } - void _init() { - zoom.value = RemoteConfigService.horarioZoom; + void moveViewportTo(BuildContext context, double x, double y) { + final viewportWidth = MediaQuery.of(context).size.width - MediaQuery.of(context).padding.horizontal; + final viewportHeight = MediaQuery.of(context).size.height - MediaQuery.of(context).padding.vertical; - _initControllers(); + x = (x + (HorarioMainScroller.periodWidth / 2)) * zoom.value - (viewportWidth / 2); + y = (y + (HorarioMainScroller.dayHeight / 2)) * zoom.value - (viewportHeight / 2); - _setScrollControllerListeners(); + x = x < 0 ? 0 : x; + y = y < 0 ? 0 : y; + + final maxXPosition = (HorarioMainScroller.daysWidth + HorarioMainScroller.periodWidth) * zoom.value - viewportWidth; + final maxYPosition = (HorarioMainScroller.periodsHeight + HorarioMainScroller.dayHeight) * zoom.value - viewportHeight + kToolbarHeight; + + x = x > maxXPosition ? maxXPosition : x; + y = y > maxYPosition ? maxYPosition : y; + + blockContentController.value = blockContentController.value..setTranslationRaw(-x, -y, 0); + periodHeaderController.value = periodHeaderController.value..setTranslationRaw(0, -y, 0); + daysHeaderController.value = daysHeaderController.value..setTranslationRaw(-x, 0, 0); + + _onChangeAnyController(); + } + + void moveViewportToPeriodIndexAndDayIndex(BuildContext context, int periodIndex, int dayIndex) { + final blockWidth = HorarioMainScroller.blockWidth; + final x = (dayIndex * blockWidth) + (blockWidth / 2); + + final blockHeight = HorarioMainScroller.blockHeight; + final y = (periodIndex * blockHeight) + (blockHeight / 2); + + moveViewportTo(context, x, y); } - Future getHorarioData(Carrera? carrera) async { - log("getHorarioData"); - final carreraId = carrera?.id; - if (carreraId == null) { + void moveViewportToCurrentPeriodAndDay(BuildContext context) { + final periodIndex = indexOfCurrentPeriod ?? 0; + final dayIndex = indexOfCurrentDayStartingAtMonday ?? 0; + + moveViewportToPeriodIndexAndDayIndex(context, periodIndex, dayIndex); + + isCenteredInCurrentPeriodAndDay.value = true; + } + + void setZoom(double zoom) { + blockContentController.value = blockContentController.value..setDiagonal(vector.Vector4(zoom, zoom, zoom, 1)); + periodHeaderController.value = periodHeaderController.value..setDiagonal(vector.Vector4(zoom, zoom, zoom, 1)); + daysHeaderController.value = daysHeaderController.value..setDiagonal(vector.Vector4(zoom, zoom, zoom, 1)); + cornerController.value = cornerController.value..setDiagonal(vector.Vector4(zoom, zoom, zoom, 1)); + + _onChangeAnyController(); + } + + void addAsignaturaAndSetColor(Asignatura asignatura, {Color? color}) { + bool hasColor = getColor(asignatura) != null; + if (hasColor) { return; } - loadingHorario.value = true; - horario.value = await HorarioService.getHorario(carreraId); - _setRandomColorsByHorario(); - loadingHorario.value = false; + + final _newColor = color ?? unusedColors[0]; + final _key = '${asignatura.codigo}_${asignatura.tipoHora}'; + usedColors.add(_newColor); + _storage.write(_key, _newColor.value); } - void _setRandomColorsByHorario() { - if (horario.value?.horario != null) { - for (var dia in horario.value!.horario!) { - for (var bloque in dia) { - if (bloque.asignatura != null) { - addAsignaturaAndSetColor(bloque.asignatura!); - } - } - } - } + Color? getColor(Asignatura? asignatura) { + if(asignatura == null) return null; + return let(_storage.read('${asignatura.codigo}_${asignatura.tipoHora}'), (dynamic element) => Color(element)); } + void setIndicatorIsOpen(bool isOpen) { + indicatorIsOpen.value = isOpen; + } + + void setOnUpdate(Function? onUpdate) => _onUpdate = onUpdate; + + void _setRandomColorsByHorario(Horario horario) => horario.horario?.forEach((dia) => dia.forEach((bloque) { + final _asignatura = bloque.asignatura; + if (_asignatura == null) { + return; + } + + addAsignaturaAndSetColor(bloque.asignatura!); + })); + void _onChangeAnyController() { - indicatorIsOpen.value = false; + setIndicatorIsOpen(true); isCenteredInCurrentPeriodAndDay.value = false; - update(); + _onUpdate?.call(); } void _setScrollControllerListeners() { @@ -144,38 +181,24 @@ class HorarioController extends GetxController { daysHeaderController.value.setTranslationRaw(xPosition, 0, 0); periodHeaderController.value.setTranslationRaw(0, yPosition, 0); - daysHeaderController.value.setDiagonal( - vector.Vector4(currentZoom, currentZoom, currentZoom, 1), - ); - periodHeaderController.value.setDiagonal( - vector.Vector4(currentZoom, currentZoom, currentZoom, 1), - ); - cornerController.value.setDiagonal( - vector.Vector4(currentZoom, currentZoom, currentZoom, 1), - ); + daysHeaderController.value.setDiagonal(vector.Vector4(currentZoom, currentZoom, currentZoom, 1),); + periodHeaderController.value.setDiagonal(vector.Vector4(currentZoom, currentZoom, currentZoom, 1)); + cornerController.value.setDiagonal(vector.Vector4(currentZoom, currentZoom, currentZoom, 1)); zoom.value = currentZoom; _onChangeAnyController(); }); daysHeaderController.addListener(() { - final xPosition = daysHeaderController.value.getTranslation().x; final currentZoom = daysHeaderController.value.getMaxScaleOnAxis(); - + final xPosition = daysHeaderController.value.getTranslation().x; final contentYPosition = blockContentController.value.getTranslation().y; - blockContentController.value - .setTranslationRaw(xPosition, contentYPosition, 0); + blockContentController.value.setTranslationRaw(xPosition, contentYPosition, 0); - blockContentController.value.setDiagonal( - vector.Vector4(currentZoom, currentZoom, currentZoom, 1), - ); - periodHeaderController.value.setDiagonal( - vector.Vector4(currentZoom, currentZoom, currentZoom, 1), - ); - cornerController.value.setDiagonal( - vector.Vector4(currentZoom, currentZoom, currentZoom, 1), - ); + blockContentController.value.setDiagonal(vector.Vector4(currentZoom, currentZoom, currentZoom, 1)); + periodHeaderController.value.setDiagonal(vector.Vector4(currentZoom, currentZoom, currentZoom, 1)); + cornerController.value.setDiagonal(vector.Vector4(currentZoom, currentZoom, currentZoom, 1)); zoom.value = currentZoom; _onChangeAnyController(); @@ -189,115 +212,15 @@ class HorarioController extends GetxController { periodHeaderController.value.setTranslationRaw(0, yPosition, 0); - blockContentController.value - .setTranslationRaw(contentXPosition, yPosition, 0); + blockContentController.value.setTranslationRaw(contentXPosition, yPosition, 0); - blockContentController.value.setDiagonal( - vector.Vector4(currentZoom, currentZoom, currentZoom, 1), - ); - daysHeaderController.value.setDiagonal( - vector.Vector4(currentZoom, currentZoom, currentZoom, 1), - ); - cornerController.value.setDiagonal( - vector.Vector4(currentZoom, currentZoom, currentZoom, 1), - ); + blockContentController.value.setDiagonal(vector.Vector4(currentZoom, currentZoom, currentZoom, 1)); + daysHeaderController.value.setDiagonal(vector.Vector4(currentZoom, currentZoom, currentZoom, 1)); + cornerController.value.setDiagonal(vector.Vector4(currentZoom, currentZoom, currentZoom, 1)); zoom.value = currentZoom; _onChangeAnyController(); }); } - void _initControllers() { - moveViewportToCurrentPeriodAndDay(); - - setZoom(zoom.value); - } - - void moveViewportToCurrentPeriodAndDay() { - final periodIndex = indexOfCurrentPeriod ?? 0; - final dayIndex = indexOfCurrentDayStartingAtMonday ?? 0; - - moveViewportToPeriodIndexAndDayIndex(periodIndex, dayIndex); - - isCenteredInCurrentPeriodAndDay.value = true; - } - - void moveViewportToPeriodIndexAndDayIndex(int periodIndex, int dayIndex) { - final blockWidth = HorarioMainScroller.blockWidth; - final x = (dayIndex * blockWidth) + (blockWidth / 2); - - final blockHeight = HorarioMainScroller.blockHeight; - final y = (periodIndex * blockHeight) + (blockHeight / 2); - - moveViewportTo(x, y); - } - - void moveViewportTo(double x, double y) { - final viewportWidth = Get.width - Get.mediaQuery.padding.horizontal; - final viewportHeight = Get.height - Get.mediaQuery.padding.vertical; - - x = (x + (HorarioMainScroller.periodWidth / 2)) * zoom.value - - (viewportWidth / 2); - y = (y + (HorarioMainScroller.dayHeight / 2)) * zoom.value - - (viewportHeight / 2); - - x = x < 0 ? 0 : x; - y = y < 0 ? 0 : y; - - final maxXPosition = - (HorarioMainScroller.daysWidth + HorarioMainScroller.periodWidth) * - zoom.value - - viewportWidth; - final maxYPosition = - (HorarioMainScroller.periodsHeight + HorarioMainScroller.dayHeight) * - zoom.value - - viewportHeight + - kToolbarHeight; - - x = x > maxXPosition ? maxXPosition : x; - y = y > maxYPosition ? maxYPosition : y; - - blockContentController.value.setTranslationRaw(-x, -y, 0); - periodHeaderController.value.setTranslationRaw(0, -y, 0); - daysHeaderController.value.setTranslationRaw(-x, 0, 0); - - _onChangeAnyController(); - } - - void setZoom(double zoom) { - blockContentController.value.setDiagonal( - vector.Vector4(zoom, zoom, zoom, 1), - ); - periodHeaderController.value.setDiagonal( - vector.Vector4(zoom, zoom, zoom, 1), - ); - daysHeaderController.value.setDiagonal( - vector.Vector4(zoom, zoom, zoom, 1), - ); - cornerController.value.setDiagonal( - vector.Vector4(zoom, zoom, zoom, 1), - ); - - _onChangeAnyController(); - } - - void addAsignaturaAndSetColor(Asignatura asignatura, {Color? color}) { - bool hasColor = getColor(asignatura) != null; - if (!hasColor) { - Color? newColor = color ?? unusedColors[0]; - _setColor(asignatura, newColor); - } - } - - Color? getColor(Asignatura asignatura) { - String key = '${asignatura.codigo}_${asignatura.tipoHora}'; - int? colorValue = _box.read(key); - return colorValue != null ? Color(colorValue) : null; - } - - void _setColor(Asignatura asignatura, Color color) { - String key = '${asignatura.codigo}_${asignatura.tipoHora}'; - usedColors.add(color); - _box.write(key, color.value); - } -} +} \ No newline at end of file diff --git a/lib/controllers/notification_controller.dart b/lib/controllers/notification_controller.dart index 7e2950f..bf281f7 100644 --- a/lib/controllers/notification_controller.dart +++ b/lib/controllers/notification_controller.dart @@ -5,8 +5,11 @@ import 'package:awesome_notifications/awesome_notifications.dart'; import 'package:awesome_notifications_fcm/awesome_notifications_fcm.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/models/asignatura.dart'; +import 'package:mi_utem/main.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; +import 'package:mi_utem/models/carrera.dart'; +import 'package:mi_utem/repositories/grades_repository.dart'; +import 'package:mi_utem/screens/asignatura/detalle/asignatura_detalle_screen.dart'; import 'package:mi_utem/services/analytics_service.dart'; class NotificationController { @@ -35,9 +38,7 @@ class NotificationController { /// Use this method to detect when the user taps on a notifications or action button @pragma("vm:entry-point") - static Future onActionReceivedMethod( - ReceivedAction receivedAction, - ) async { + static Future onActionReceivedMethod(ReceivedAction receivedAction) async { log("onActionReceivedMethod: ${receivedAction.id} ${receivedAction.payload}"); AnalyticsService.logEvent( @@ -49,13 +50,19 @@ class NotificationController { if (type == 'grade_change') { final asignaturaJsonString = payload?['asignatura']; - if (asignaturaJsonString != null) { - AnalyticsService.logEvent( - 'notification_tap_grade_change', - ); - final asignatura = - Asignatura.fromJson(jsonDecode(asignaturaJsonString)); - Get.toNamed('${Routes.asignatura}/${asignatura.id}'); + final carreraPayload = payload?['carrera']; + if (asignaturaJsonString != null && carreraPayload != null) { + AnalyticsService.logEvent('notification_tap_grade_change'); + final carrera = Carrera.fromJson(jsonDecode(carreraPayload)); + final asignatura = Asignatura.fromJson(jsonDecode(asignaturaJsonString)); + try { // Intenta actualizar las notas. + asignatura.grades = await Get.find().getGrades(carreraId: carrera.id, asignaturaId: asignatura.id, forceRefresh: true); + } catch(_){} + + navigatorKey.currentState?.push(MaterialPageRoute(builder: (ctx) => AsignaturaDetalleScreen( + carrera: carrera, + asignatura: asignatura, + ))); } } } diff --git a/lib/controllers/qr_pass_controller.dart b/lib/controllers/qr_pass_controller.dart deleted file mode 100644 index e879f6a..0000000 --- a/lib/controllers/qr_pass_controller.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:get/get.dart'; -import 'package:mi_utem/models/permiso_covid.dart'; -import 'package:mi_utem/services/permisos_covid_service.dart'; - -class QrPassController extends GetxController with StateMixin { - late final String passId; - - QrPassController(this.passId); - - @override - void onInit() { - getPassDetails(); - super.onInit(); - } - - void getPassDetails({bool refresh = false}) async { - change(null, status: RxStatus.loading()); - try { - PermisoCovid pass = await PermisosCovidService.getDetallesPermiso( - passId, - forceRefresh: refresh, - ); - change(pass, status: RxStatus.success()); - } catch (e) { - change(null, status: RxStatus.error(e.toString())); - } - } -} diff --git a/lib/controllers/qr_passes_controller.dart b/lib/controllers/qr_passes_controller.dart deleted file mode 100644 index c92b8d0..0000000 --- a/lib/controllers/qr_passes_controller.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:get/get.dart'; -import 'package:mi_utem/controllers/qr_pass_controller.dart'; -import 'package:mi_utem/models/permiso_covid.dart'; -import 'package:mi_utem/services/permisos_covid_service.dart'; - -class QrPassesController extends GetxController { - final passes = [].obs; - final isLoading = false.obs; - - @override - void onInit() { - getPasses(); - super.onInit(); - } - - void getPasses({bool refresh = false}) async { - isLoading.value = true; - List response = - await PermisosCovidService.getPermisos(forceRefresh: refresh); - passes.value = response; - isLoading.value = false; - - for (var pass in passes) { - if (pass.id != null) { - Get.put( - QrPassController(pass.id!), - tag: pass.id, - permanent: true, - ); - } - } - } -} - -class QrPassesBinding extends Bindings { - @override - void dependencies() { - Get.put(QrPassesController()); - } -} diff --git a/lib/helpers/snackbars.dart b/lib/helpers/snackbars.dart deleted file mode 100644 index 4a0d038..0000000 --- a/lib/helpers/snackbars.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; - -void showDefaultSnackbar(String title, String message) { - Get.snackbar( - title, - message, - colorText: Colors.white, - backgroundColor: Get.theme.primaryColor, - snackPosition: SnackPosition.BOTTOM, - margin: EdgeInsets.all(20), - ); -} diff --git a/lib/main.dart b/lib/main.dart index e48103c..6e49ed0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,5 @@ import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -9,39 +8,34 @@ import 'package:flutter_uxcam/flutter_uxcam.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:mi_utem/config/constants.dart'; -import 'package:mi_utem/config/router.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/controllers/calculator_controller.dart'; -import 'package:mi_utem/controllers/carreras_controller.dart'; -import 'package:mi_utem/services/analytics_service.dart'; -import 'package:mi_utem/services/auth_service.dart'; +import 'package:mi_utem/screens/splash_screen.dart'; +import 'package:mi_utem/service_manager.dart'; import 'package:mi_utem/services/background_service.dart'; import 'package:mi_utem/services/notification_service.dart'; -import 'package:mi_utem/services/perfil_service.dart'; import 'package:mi_utem/themes/theme.dart'; -import 'package:responsive_framework/responsive_framework.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'services/remote_config/remote_config.dart'; +final GlobalKey navigatorKey = GlobalKey(); + void main() async { WidgetsFlutterBinding.ensureInitialized(); - await dotenv.load(); await GetStorage.init(); - await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + await SystemChrome.setPreferredOrientations(DeviceOrientation.values); await Firebase.initializeApp(); await RemoteConfigService.initialize(); + await registerServices(); await NotificationService.initialize(); await BackgroundService.initAndStart(); - await SentryFlutter.init( - (options) { - options.dsn = Constants.sentryDsn; - options.attachScreenshot = true; - options.tracesSampleRate = 1.0; - }, - appRunner: () => runApp(MiUtem()), - ); + await SentryFlutter.init((options) { + options.dsn = sentryDsn; + options.attachScreenshot = true; + options.tracesSampleRate = 1.0; + options.enableMetrics = true; + options.attachViewHierarchy = true; + }, appRunner: () => runApp(SentryWidget(child: MiUtem()))); } class MiUtem extends StatefulWidget { @@ -52,54 +46,26 @@ class MiUtem extends StatefulWidget { class _MiUtemState extends State { final FirebaseAnalytics analytics = FirebaseAnalytics.instance; - final calculatorController = Get.put(CalculatorController()); - - @override - void initState() { - if (AuthService.isLoggedIn()) { - final user = PerfilService.getLocalUsuario(); - AnalyticsService.setUser(user); - } else { - AnalyticsService.removeUser(); - } - super.initState(); - } - @override Widget build(BuildContext context) { FlutterUxcam.optIntoSchematicRecordings(); - FlutterUxConfig config = FlutterUxConfig( - userAppKey: kDebugMode ? Constants.uxCamDevKey : Constants.uxCamProdKey, + FlutterUxcam.startWithConfiguration(FlutterUxConfig( + userAppKey: kDebugMode ? uxCamDevKey : uxCamProdKey, enableAutomaticScreenNameTagging: true, enableMultiSessionRecord: true, - ); - FlutterUxcam.startWithConfiguration(config); + )); return GetMaterialApp( - getPages: pages, - initialRoute: Routes.splash, + home: SplashScreen(), debugShowCheckedModeBanner: false, title: 'Mi UTEM', - initialBinding: BindingsBuilder(() { - Get.put(CarrerasController(), permanent: true); - }), theme: MainTheme.theme, navigatorObservers: [ FirebaseAnalyticsObserver(analytics: analytics), SentryNavigatorObserver(), FlutterUxcamNavigatorObserver() ], - builder: (context, widget) => ResponsiveWrapper.builder( - widget, - maxWidth: 1200, - minWidth: 360, - breakpoints: [ - ResponsiveBreakpoint.resize(480, name: MOBILE), - ResponsiveBreakpoint.autoScale(800, name: TABLET), - ResponsiveBreakpoint.resize(1000, name: DESKTOP), - ResponsiveBreakpoint.autoScale(2460, name: '4K'), - ], - ), + defaultTransition: Transition.native, ); } } diff --git a/lib/models/asignatura.dart b/lib/models/asignatura.dart deleted file mode 100644 index abf2045..0000000 --- a/lib/models/asignatura.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:mi_utem/models/grades.dart'; -import 'package:mi_utem/models/usuario.dart'; -import 'package:mi_utem/themes/theme.dart'; -import 'package:recase/recase.dart'; - -class Asignatura { - String? id; - String? nombre; - String? codigo; - String? tipoHora; - String? estado; - String? docente; - String? seccion; - - Asistencia? asistencia; - Grades? grades; - List? estudiantes; - String? tipoAsignatura; - num? intentos; - String? horario; - String? sala; - String? tipoSala; - - Asignatura({ - this.id, - this.nombre, - this.codigo, - this.tipoHora, - this.estado, - this.docente, - this.seccion, - this.asistencia, - this.grades, - this.estudiantes, - this.tipoAsignatura, - this.sala, - this.horario, - this.intentos, - this.tipoSala, - }); - - Color get colorPorEstado { - switch (estado) { - case "Aprobado": - return MainTheme.aprobadoColor; - case "Reprobado": - return MainTheme.reprobadoColor; - default: - return MainTheme.inscritoColor; - } - } - - factory Asignatura.fromJson(Map? json) { - if (json == null) { - return Asignatura(); - } - - return Asignatura( - id: json['id'], - codigo: json['codigo'], - nombre: ReCase(json['nombre'] ?? '').titleCase, - tipoHora: ReCase(json['tipoHora'] ?? '').titleCase, - estado: ReCase(json['estado'] ?? '').titleCase, - docente: ReCase(json['docente'] ?? '').titleCase, - seccion: json['seccion'], - grades: json['notas'] != null ? Grades.fromJson(json['notas']) : null, - // estudiantes: Usuario.fromJsonList(json["estudiantes"]), - asistencia: Asistencia(asistidos: json['asistenciaAlDia']), - // tipoAsignatura: ReCase(json['tipoAsignatura'].toString()).titleCase, - sala: ReCase(json['sala'] ?? '').titleCase, - horario: json['horario'], - intentos: - json['intentos'] != null ? int.parse(json['intentos'].toString()) : 0, - tipoSala: ReCase(json['tipoSala'] ?? '').titleCase, - ); - } - - static List fromJsonList(dynamic json) { - if (json == null) { - return []; - } - List list = []; - for (var item in json) { - list.add(Asignatura.fromJson(item)); - } - return list; - } - - Map toJson() { - return { - 'id': id, - 'codigo': codigo, - 'nombre': nombre, - 'tipoHora': tipoHora, - 'estado': estado, - 'docente': docente, - 'seccion': seccion, - 'estudiantes': estudiantes, - 'notas': grades?.toJson(), - 'asistencia': asistencia?.toJson(), - 'tipoAsignatura': tipoAsignatura, - 'sala': sala, - 'horario': horario, - 'intentos': intentos, - 'tipoSala': tipoSala, - }; - } -} - -class Asistencia { - num? total; - num? asistidos; - num? noAsistidos; - num? sinRegistro; - - Asistencia({ - this.total = 0, - this.asistidos = 0, - this.noAsistidos = 0, - this.sinRegistro = 0, - }); - - factory Asistencia.fromJson(Map? json) { - if (json == null) { - return Asistencia(); - } - return Asistencia( - total: json['total'] ?? 0, - asistidos: json['asistida'] ?? 0, - noAsistidos: json['noAsistidos'] ?? 0, - sinRegistro: json['sinRegistro'] ?? 0, - ); - } - - static List fromJsonList(dynamic json) { - if (json == null) { - return []; - } - List list = []; - for (var item in json) { - list.add(Asistencia.fromJson(item)); - } - return list; - } - - Map toJson() { - return { - 'total': total, - 'asistidos': asistidos, - 'noAsistidos': noAsistidos, - 'sinRegistro': sinRegistro, - }; - } -} diff --git a/lib/models/asignaturas/asignatura.dart b/lib/models/asignaturas/asignatura.dart new file mode 100644 index 0000000..4eb3348 --- /dev/null +++ b/lib/models/asignaturas/asignatura.dart @@ -0,0 +1,153 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/asignaturas/asistencia.dart'; +import 'package:mi_utem/models/evaluacion/grades.dart'; +import 'package:mi_utem/models/user/persona.dart'; +import 'package:mi_utem/models/user/rut.dart'; +import 'package:mi_utem/models/user/user.dart'; +import 'package:mi_utem/themes/theme.dart'; +import 'package:mi_utem/utils/string_utils.dart'; +import 'package:mi_utem/utils/utils.dart'; + +class Asignatura { + String id; + String nombre; + String codigo; + String tipoHora; + String estado; + String seccion; + Persona docente; + + Asistencia? asistencia; + Grades? grades; + List? estudiantes; + String? tipoAsignatura; + num? intentos; + String? horario; + String? sala; + String? tipoSala; + + Asignatura({ + required this.id, + required this.nombre, + required this.codigo, + required this.tipoHora, + required this.estado, + required this.seccion, + required this.docente, + this.asistencia, + this.grades, + this.estudiantes, + this.tipoAsignatura, + this.sala, + this.horario, + this.intentos, + this.tipoSala, + }); + + Color get colorPorEstado { + switch (estado) { + case "Aprobado": + return MainTheme.aprobadoColor; + case "Reprobado": + return MainTheme.reprobadoColor; + default: + return MainTheme.inscritoColor; + } + } + + factory Asignatura.fromJson(Map json) => Asignatura( + id: json['id'], + codigo: json['codigo'], + nombre: capitalize(json['nombre'] ?? ''), + tipoHora: capitalize(json['tipoHora'] ?? ''), + estado: capitalize(json['estado'] ?? ''), + docente: let(json['docente'], (String? docente) { + if(docente == null) { + return null; + } + + final partes = docente.split("-"); + if(partes.isEmpty) { + return null; + } + + if(partes.length == 1) { + return Persona(nombreCompleto: capitalize(partes.first)); + } + + final numeroRut = int.tryParse(partes.first); + return Persona( + nombreCompleto: capitalize(partes.last.trim()), + rut: numeroRut != null ? Rut(numeroRut) : null, + ); + }) ?? Persona(nombreCompleto: "Sin Docente"), + seccion: json['seccion'], + grades: json['notas'] != null ? Grades.fromJson(json['notas']) : Grades(), + estudiantes: json.containsKey("estudiantes") ? User.fromJsonList(json["estudiantes"]) : [], + asistencia: Asistencia(asistidos: json['asistenciaAlDia']), + tipoAsignatura: capitalize(json['tipoAsignatura'] as String? ?? ''), + sala: capitalize(json['sala'] ?? ''), + horario: json['horario'], + intentos: json.containsKey('intentos') ? (json['intentos'] is num ? json['intentos'] as num? : num.tryParse(json['intentos'])) : null, + tipoSala: capitalize(json['tipoSala'] ?? ''), + ); + + static List fromJsonList(dynamic json) => json != null ? (json as List).map((it) => Asignatura.fromJson(it)).toList() : []; + + Map toJson() => { + 'id': id, + 'codigo': codigo, + 'nombre': nombre, + 'tipoHora': tipoHora, + 'estado': estado, + 'docente': docente, + 'seccion': seccion, + 'estudiantes': estudiantes, + 'notas': grades?.toJson() ?? [], + 'asistencia': asistencia?.toJson() ?? {}, + 'tipoAsignatura': tipoAsignatura, + 'sala': sala, + 'horario': horario, + 'intentos': intentos, + 'tipoSala': tipoSala, + }; + + @override + String toString() => jsonEncode(toJson()); + + Asignatura copyWith({ + String? id, + String? nombre, + String? codigo, + String? tipoHora, + String? estado, + Persona? docente, + String? seccion, + Asistencia? asistencia, + Grades? grades, + List? estudiantes, + String? tipoAsignatura, + String? sala, + String? horario, + num? intentos, + String? tipoSala, + }) => Asignatura( + id: id ?? this.id, + nombre: nombre ?? this.nombre, + codigo: codigo ?? this.codigo, + tipoHora: tipoHora ?? this.tipoHora, + estado: estado ?? this.estado, + docente: docente ?? this.docente, + seccion: seccion ?? this.seccion, + asistencia: asistencia ?? this.asistencia, + grades: grades ?? this.grades, + estudiantes: estudiantes ?? this.estudiantes, + tipoAsignatura: tipoAsignatura ?? this.tipoAsignatura, + sala: sala ?? this.sala, + horario: horario ?? this.horario, + intentos: intentos ?? this.intentos, + tipoSala: tipoSala ?? this.tipoSala, + ); +} \ No newline at end of file diff --git a/lib/models/asignaturas/asistencia.dart b/lib/models/asignaturas/asistencia.dart new file mode 100644 index 0000000..9d2ec40 --- /dev/null +++ b/lib/models/asignaturas/asistencia.dart @@ -0,0 +1,29 @@ +class Asistencia { + num? total; + num? asistidos; + num? noAsistidos; + num? sinRegistro; + + Asistencia({ + this.total = 0, + this.asistidos = 0, + this.noAsistidos = 0, + this.sinRegistro = 0, + }); + + factory Asistencia.fromJson(Map? json) => json != null ? Asistencia( + total: json['total'] ?? 0, + asistidos: json['asistida'] ?? 0, + noAsistidos: json['noAsistidos'] ?? 0, + sinRegistro: json['sinRegistro'] ?? 0, + ) : Asistencia(); + + static List fromJsonList(dynamic json) => json != null ? (json as List).map((it) => Asistencia.fromJson(it)).toList() : []; + + Map toJson() => { + 'total': total, + 'asistidos': asistidos, + 'noAsistidos': noAsistidos, + 'sinRegistro': sinRegistro, + }; +} diff --git a/lib/models/asignaturas/detalles/navigation_tab.dart b/lib/models/asignaturas/detalles/navigation_tab.dart new file mode 100644 index 0000000..7cb92c4 --- /dev/null +++ b/lib/models/asignaturas/detalles/navigation_tab.dart @@ -0,0 +1,13 @@ +import 'package:flutter/widgets.dart'; + +class NavigationTab { + final String label; + final Widget child; + final bool initial; + + NavigationTab({ + required this.label, + required this.child, + this.initial = false, + }); +} \ No newline at end of file diff --git a/lib/models/carrera.dart b/lib/models/carrera.dart index e42b32b..9f4331b 100644 --- a/lib/models/carrera.dart +++ b/lib/models/carrera.dart @@ -1,4 +1,6 @@ -import 'package:recase/recase.dart'; +import 'dart:convert'; + +import 'package:mi_utem/utils/string_utils.dart'; class Carrera { String? id; @@ -9,27 +11,24 @@ class Carrera { Carrera({this.id, this.nombre, this.estado, this.codigo, this.plan}); - factory Carrera.fromJson(Map? json) { - if (json == null) { - return Carrera(); - } - return Carrera( - id: json['id'], - nombre: json['nombre'] != null ? ReCase(json['nombre']).titleCase : null, - estado: json['estado'] != null ? ReCase(json['estado']).titleCase : null, - plan: json['plan'] != null ? ReCase(json['plan']).titleCase : null, - codigo: json['codigo'], - ); - } + factory Carrera.fromJson(Map? json) => json != null ? Carrera( + id: json['id'], + nombre: json['nombre'] != null ? capitalize(json['nombre']) : null, + estado: json['estado'] != null ? capitalize(json['estado']) : null, + plan: json['plan'] != null ? capitalize(json['plan']) : null, + codigo: json['codigo'], + ) : Carrera(); + + static List fromJsonList(dynamic json) => json != null ? (json as List).map((it) => Carrera.fromJson(it)).toList() : []; + + toJson() => { + 'id': id, + 'nombre': nombre, + 'estado': estado, + 'codigo': codigo, + 'plan': plan, + }; - static List fromJsonList(dynamic json) { - if (json == null) { - return []; - } - List list = []; - for (var item in json) { - list.add(Carrera.fromJson(item)); - } - return list; - } + @override + String toString() => jsonEncode(toJson()); } diff --git a/lib/models/evaluacion.dart b/lib/models/evaluacion.dart deleted file mode 100644 index 4acb870..0000000 --- a/lib/models/evaluacion.dart +++ /dev/null @@ -1,78 +0,0 @@ -class REvaluacion { - String? descripcion; - num? porcentaje; - num? nota; - - static const porcentajeKey = "porcentaje"; - static const descripcionKey = "descripcion"; - static const notaKey = "nota"; - - REvaluacion({ - this.descripcion, - this.porcentaje, - this.nota, - }); - - factory REvaluacion.fromJson(Map? json) { - if (json == null) { - return REvaluacion(); - } - return REvaluacion( - porcentaje: json[porcentajeKey], - descripcion: json[descripcionKey], - nota: json[notaKey], - ); - } - - static List fromJsonList(dynamic json) { - if (json == null) { - return []; - } - List list = []; - for (var item in json) { - list.add(REvaluacion.fromJson(item)); - } - return list; - } - - Map toJson() { - return { - porcentajeKey: porcentaje, - descripcionKey: descripcion, - notaKey: nota, - }; - } -} - -class IEvaluacion extends REvaluacion { - bool editable; - - IEvaluacion({ - this.editable = false, - String? descripcion, - num? porcentaje, - num? nota, - }) : super( - descripcion: descripcion, - porcentaje: porcentaje, - nota: nota, - ); - - factory IEvaluacion.fromRemote(REvaluacion evaluacion) { - return IEvaluacion( - descripcion: evaluacion.descripcion, - porcentaje: evaluacion.porcentaje, - nota: evaluacion.nota, - ); - } - - IEvaluacion copyWith( - {bool? editable, String? descripcion, num? porcentaje, num? nota}) { - return IEvaluacion( - editable: editable ?? this.editable, - descripcion: descripcion ?? this.descripcion, - porcentaje: porcentaje ?? this.porcentaje, - nota: nota ?? this.nota, - ); - } -} diff --git a/lib/models/evaluacion/evaluacion.dart b/lib/models/evaluacion/evaluacion.dart new file mode 100644 index 0000000..a3326f0 --- /dev/null +++ b/lib/models/evaluacion/evaluacion.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; + +class REvaluacion { + String? descripcion; + num? porcentaje; + num? nota; + + static const porcentajeKey = "porcentaje"; + static const descripcionKey = "descripcion"; + static const notaKey = "nota"; + + REvaluacion({ + this.descripcion, + this.porcentaje, + this.nota, + }); + + factory REvaluacion.fromJson(Map? json) => + json != null ? REvaluacion( + porcentaje: json[porcentajeKey], + descripcion: json[descripcionKey], + nota: json[notaKey], + ) : REvaluacion(); + + static List fromJsonList(List? json) => + json?.map((it) => REvaluacion.fromJson(it)).toList() ?? []; + + Map toJson() => { + porcentajeKey: porcentaje, + descripcionKey: descripcion, + notaKey: nota, + }; + + @override + String toString() => jsonEncode(toJson()); +} + +class IEvaluacion extends REvaluacion { + bool editable; + + IEvaluacion({ + this.editable = false, + String? descripcion, + num? porcentaje, + num? nota, + }) : super( + descripcion: descripcion, + porcentaje: porcentaje, + nota: nota, + ); + + factory IEvaluacion.fromRemote(REvaluacion evaluacion) => IEvaluacion( + descripcion: evaluacion.descripcion, + porcentaje: evaluacion.porcentaje, + nota: evaluacion.nota, + ); + + IEvaluacion copyWith({bool? editable, String? descripcion, num? porcentaje, num? nota}) => IEvaluacion( + editable: editable ?? this.editable, + descripcion: descripcion ?? this.descripcion, + porcentaje: porcentaje ?? this.porcentaje, + nota: nota ?? this.nota, + ); + + @override + Map toJson() => { + ...super.toJson(), + "editable": editable, + }; + + @override + String toString() => jsonEncode(toJson()); +} diff --git a/lib/models/evaluacion/grades.dart b/lib/models/evaluacion/grades.dart new file mode 100644 index 0000000..79fb0ae --- /dev/null +++ b/lib/models/evaluacion/grades.dart @@ -0,0 +1,29 @@ +import 'package:mi_utem/models/evaluacion/evaluacion.dart'; + +class Grades { + List notasParciales; + num? notaFinal; + num? notaPresentacion; + num? notaExamen; + + Grades({ + this.notasParciales = const [], + this.notaFinal, + this.notaPresentacion, + this.notaExamen, + }); + + factory Grades.fromJson(Map json) => Grades( + notasParciales: REvaluacion.fromJsonList(json['notasParciales']), + notaFinal: json['notaFinal'] as num?, + notaPresentacion: json['notaPresentacion'] as num?, + notaExamen: json['notaExamen'] as num?, + ); + + Map toJson() => { + 'notasParciales': notasParciales.map((nota) => nota.toJson()).toList(), + 'notaFinal': notaFinal, + 'notaPresentacion': notaPresentacion, + 'notaExamen': notaExamen, + }; +} diff --git a/lib/models/exceptions/custom_exception.dart b/lib/models/exceptions/custom_exception.dart new file mode 100644 index 0000000..b669748 --- /dev/null +++ b/lib/models/exceptions/custom_exception.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +class CustomException implements Exception { + + final String message; + final String? error; + final int? statusCode; + final double? internalCode; + + CustomException({ + this.message = 'Ocurrió un error inesperado. Por favor, inténtalo nuevamente.', + this.error, + this.statusCode, + this.internalCode, + }); + + factory CustomException.unknown() => CustomException.custom(null); + + factory CustomException.custom(String? errorMessage) => CustomException(message: "Ha ocurrido un error inesperado. ${errorMessage ?? "Por favor intenta más tarde."}"); + + factory CustomException.fromJson(Map json) => CustomException( + message: json['mensaje'] as String, + error: json['error'] as String?, + statusCode: json['codigoHttp'] as int?, + internalCode: json['codigoInterno'] is num ? double.tryParse("${json['codigoInterno']}") : json['codigoInterno'] as double?, + ); + + toJson() => { + 'mensaje': message, + 'error': error, + 'codigoHttp': statusCode, + 'codigoInterno': internalCode, + }; + + @override + String toString() => jsonEncode(toJson()); +} \ No newline at end of file diff --git a/lib/models/grades.dart b/lib/models/grades.dart deleted file mode 100644 index 9719262..0000000 --- a/lib/models/grades.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:mi_utem/models/evaluacion.dart'; - -class Grades { - List notasParciales; - num? notaFinal; - num? notaPresentacion; - num? notaExamen; - - Grades({ - this.notasParciales = const [], - this.notaFinal, - this.notaPresentacion, - this.notaExamen, - }); - - factory Grades.fromJson(Map json) { - return Grades( - notasParciales: REvaluacion.fromJsonList(json['notasParciales']), - notaFinal: json['notaFinal'] as num?, - notaPresentacion: json['notaPresentacion'] as num?, - notaExamen: json['notaExamen'] as num?, - ); - } - - Map toJson() { - return { - 'notasParciales': notasParciales.map((nota) => nota.toJson()).toList(), - 'notaFinal': notaFinal, - 'notaPresentacion': notaPresentacion, - 'notaExamen': notaExamen, - }; - } -} diff --git a/lib/models/horario.dart b/lib/models/horario.dart index 5f56230..ae004c2 100644 --- a/lib/models/horario.dart +++ b/lib/models/horario.dart @@ -1,6 +1,4 @@ -import 'dart:developer'; - -import 'package:mi_utem/models/asignatura.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; class Horario { List? asignaturas; @@ -63,83 +61,61 @@ class Horario { Horario({this.asignaturas, this.horario, this.dias, this.periodos}); - factory Horario.fromJson(Map? json) { - if (json == null) { - return Horario(); - } - return Horario( - // asignaturas: Asignatura.fromJsonList(json['asignaturas']), - horario: BloqueHorario.fromJsonMatrix(json['horario']), - // dias: json["dias"], - // periodos: Periodo.fromJsonList(json["periodos"]), - ); - } - - static List fromJsonList(dynamic json) { - if (json == null) { - return []; - } - List list = []; - for (var bloque in json) { - log('bloque: $bloque'); - for (var dia in bloque) { - log('dia: $dia'); - list.add(Horario.fromJson(dia)); - } - } - return list; - } - - List get horasInicio { - return [ - "08:00", - "09:40", - "11:20", - "13:00", - "14:40", - "16:20", - "18:00", - "19:40", - "21:20" - ]; - } + factory Horario.fromJson(Map? json) => json != null ? Horario( + // asignaturas: Asignatura.fromJsonList(json['asignaturas']), + horario: BloqueHorario.fromJsonMatrix(json['horario']), + // dias: json["dias"], + // periodos: Periodo.fromJsonList(json["periodos"]), + ) : Horario(); + + static List fromJsonList(dynamic json) => (json as List? ?? []).expand((bloque) => (bloque as List? ?? []).map((dia) => Horario.fromJson(dia))).toList(); + + List get horasInicio => [ + "08:00", + "09:40", + "11:20", + "13:00", + "14:40", + "16:20", + "18:00", + "19:40", + "21:20" + ]; - List get horasIntermedio { - return [ - "08:45", - "10:25", - "12:05", - "13:45", - "15:25", - "17:05", - "18:45", - "20:25", - "22:05" - ]; - } + List get horasIntermedio => [ + "08:45", + "10:25", + "12:05", + "13:45", + "15:25", + "17:05", + "18:45", + "20:25", + "22:05" + ]; - List get horasTermino { - return [ - "09:30", - "11:10", - "12:50", - "14:30", - "16:10", - "17:50", - "19:30", - "21:10", - "22:50" - ]; - } + List get horasTermino => [ + "09:30", + "11:10", + "12:50", + "14:30", + "16:10", + "17:50", + "19:30", + "21:10", + "22:50" + ]; - List get diasHorario { - return ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"]; - } + List get diasHorario => ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"]; List> get horarioEnlazado { + final _horario = horario; List> horarioNuevo = []; + if(_horario == null) { + return []; + } - for (List fila in horario!) { + for (List fila in _horario) { List filaNueva = []; for (BloqueHorario bloque in fila) { filaNueva.add(bloque); @@ -156,30 +132,21 @@ class Periodo { String? horaIntermedio; String? horaTermino; - Periodo( - {this.numero, this.horaInicio, this.horaIntermedio, this.horaTermino}); + Periodo({ + this.numero, + this.horaInicio, + this.horaIntermedio, + this.horaTermino, + }); - factory Periodo.fromJson(Map? json) { - if (json == null) { - return Periodo(); - } - return Periodo( - numero: json["numero"], - horaInicio: json["horaInicio"], - horaIntermedio: json["horaIntermedio"], - horaTermino: json["horaTermino"]); - } + factory Periodo.fromJson(Map? json) => json != null ? Periodo( + numero: json["numero"], + horaInicio: json["horaInicio"], + horaIntermedio: json["horaIntermedio"], + horaTermino: json["horaTermino"], + ) : Periodo(); - static List fromJsonList(dynamic json) { - if (json == null) { - return []; - } - List list = []; - for (var item in json) { - list.add(Periodo.fromJson(item)); - } - return list; - } + static List fromJsonList(dynamic json) => json != null ? (json as List).map((item) => Periodo.fromJson(item)).toList() : []; } class BloqueHorario { @@ -193,33 +160,11 @@ class BloqueHorario { this.codigo, }); - factory BloqueHorario.fromJson(Map? json) { - if (json == null) { - return BloqueHorario(); - } - - BloqueHorario bloque = BloqueHorario( - asignatura: Asignatura.fromJson(json['asignatura']), - sala: json['asignatura']['sala'], - codigo: - "${json['asignatura']['codigo']}/${json['asignatura']['seccion']}"); - - return bloque; - } + factory BloqueHorario.fromJson(Map? json) => json != null ? BloqueHorario( + asignatura: Asignatura.fromJson(json['asignatura']), + sala: json['asignatura']['sala'], + codigo: "${json['asignatura']['codigo']}/${json['asignatura']['seccion']}", + ) : BloqueHorario(); - static List>? fromJsonMatrix(dynamic json) { - if (json == null) { - return null; - } - List> matrix = []; - for (var bloque in json) { - List list = []; - for (var dia in bloque) { - log('dia: $dia'); - list.add(BloqueHorario.fromJson(dia)); - } - matrix.add(list); - } - return matrix; - } + static List>? fromJsonMatrix(dynamic json) => json == null ? null : (json as List? ?? []).map((bloque) => (bloque as List? ?? []).map((dia) => BloqueHorario.fromJson(dia)).toList()).toList(); } diff --git a/lib/models/noticia.dart b/lib/models/noticia.dart index 461b4e2..eb51116 100644 --- a/lib/models/noticia.dart +++ b/lib/models/noticia.dart @@ -1,67 +1,15 @@ -import 'package:html/parser.dart'; - class Noticia { - int? id, featuredMediaId; - String? titulo, subtitulo, link; - FeaturedMedia? featuredMedia; - - - Noticia( - this.id, - this.titulo, - this.subtitulo, - this.link, - this.featuredMediaId - ); + int id; + String titulo, link, imagen; - Noticia.empty() - : id = null, - titulo = "", - subtitulo = "", - link = "", - featuredMedia = FeaturedMedia.empty(), - featuredMediaId = null; + Noticia({required this.id, required this.titulo, required this.link, required this.imagen}); - factory Noticia.fromJson(Map json) { - return Noticia( - json['id'], - parse(json['title']['rendered']).body!.text.trim(), - parse(json['excerpt']['rendered']).body!.text.trim(), - json['link'], - json['featured_media'] - ); - } - - static List fromJsonList(List json) { - List lista = []; - for (var elemento in json) { - lista.add(Noticia.fromJson(elemento)); - } - return lista; - } -} - -class FeaturedMedia { - int? id; - String? guid; - - - FeaturedMedia({ - this.id, - this.guid - }); - - factory FeaturedMedia.empty() { - return FeaturedMedia( - id: null, - guid: "https://noticias.utem.cl/wp-content/uploads/2017/07/en-preparacion.jpg" - ); - } + factory Noticia.fromJson(Map json) => Noticia( + id: json["id"], + titulo: json['yoast_head_json']['title'], + imagen: json['yoast_head_json']['og_image'][0]['url'], + link: "https://noticias.utem.cl/?p=${json['id']}", + ); - factory FeaturedMedia.fromJson(Map json) { - return FeaturedMedia( - id: json['id'], - guid: json['guid'] != null && json['guid']['rendered'] != null && json['guid']['rendered'] != "" ? json['guid']['rendered'] : "https://noticias.utem.cl/wp-content/uploads/2017/07/en-preparacion.jpg" - ); - } + static List fromJsonList(List json) => json.map((e) => Noticia.fromJson(e)).toList(); } diff --git a/lib/models/novedades/ibanner.dart b/lib/models/novedades/ibanner.dart new file mode 100644 index 0000000..8cc80ff --- /dev/null +++ b/lib/models/novedades/ibanner.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:hexcolor/hexcolor.dart'; + +class IBanner { + final String id; + final String name; + final Color backgroundColor; + final String url; + final String imageUrl; + + const IBanner({ + required this.id, + required this.name, + required this.backgroundColor, + required this.url, + required this.imageUrl, + }); + + factory IBanner.fromJson(Map json) => IBanner( + id: json["id"], + name: json["name"], + backgroundColor: HexColor(json["backgroundColor"]), + url: json["url"], + imageUrl: json["imageUrl"], + ); + + static List fromJsonList(dynamic json) => json != null ? (json as List).map((x) => IBanner.fromJson(x)).toList() : []; +} \ No newline at end of file diff --git a/lib/models/pair.dart b/lib/models/pair.dart new file mode 100644 index 0000000..e288222 --- /dev/null +++ b/lib/models/pair.dart @@ -0,0 +1,16 @@ +import 'dart:convert'; + +class Pair { + final A a; + final B b; + + Pair(this.a, this.b); + + toJson() => { + 'a': a, + 'b': b, + }; + + @override + String toString() => jsonEncode(toJson()); +} \ No newline at end of file diff --git a/lib/models/permiso_covid.dart b/lib/models/permiso_covid.dart deleted file mode 100644 index 336aae0..0000000 --- a/lib/models/permiso_covid.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:mi_utem/models/usuario.dart'; - -class PermisoCovid { - String? id; - Usuario? usuario; - String? codigoQr; - String? perfil; - String? motivo; - String? campus; - String? dependencia; - String? jornada; - String? vigencia; - DateTime? fechaSolicitud; - - PermisoCovid({ - this.id, - this.usuario, - this.codigoQr, - this.perfil, - this.motivo, - this.campus, - this.dependencia, - this.jornada, - this.vigencia, - this.fechaSolicitud, - }); - - factory PermisoCovid.fromJson(Map json) { - return PermisoCovid( - id: json['id'], - usuario: Usuario.fromJson(json['usuario']), - codigoQr: json['codigoQr'], - perfil: json['perfil'], - motivo: json['motivo'], - campus: json['campus'], - dependencia: json['dependencia'], - jornada: json['jornada'], - vigencia: json['vigencia'], - fechaSolicitud: DateTime.tryParse(json['fechaSolicitud']), - ); - } - - static List fromJsonList(List json) { - List lista = []; - for (var elemento in json) { - lista.add(PermisoCovid.fromJson(elemento)); - } - return lista; - } -} diff --git a/lib/models/permiso_ingreso.dart b/lib/models/permiso_ingreso.dart new file mode 100644 index 0000000..59d6cfc --- /dev/null +++ b/lib/models/permiso_ingreso.dart @@ -0,0 +1,42 @@ +import 'package:mi_utem/models/user/user.dart'; + +class PermisoIngreso { + String? id; + User? user; + String? codigoQr; + String? perfil; + String? motivo; + String? campus; + String? dependencia; + String? jornada; + String? vigencia; + DateTime? fechaSolicitud; + + PermisoIngreso({ + this.id, + this.user, + this.codigoQr, + this.perfil, + this.motivo, + this.campus, + this.dependencia, + this.jornada, + this.vigencia, + this.fechaSolicitud, + }); + + factory PermisoIngreso.fromJson(Map? json) => json != null ? PermisoIngreso( + id: json['id'], + user: json.containsKey("usuario") ? User.fromJson(json['usuario']) : null, + codigoQr: json['codigoQr'], + perfil: json['perfil'], + motivo: json['motivo'], + campus: json['campus'], + dependencia: json['dependencia'], + jornada: json['jornada'], + vigencia: json['vigencia'], + fechaSolicitud: DateTime.tryParse(json['fechaSolicitud']), + ) : PermisoIngreso(); + + static List fromJsonList(List? json) => json != null ? json.map((it) => PermisoIngreso.fromJson(it)).toList() : []; +} diff --git a/lib/models/preferencia.dart b/lib/models/preferencia.dart new file mode 100644 index 0000000..1ec1585 --- /dev/null +++ b/lib/models/preferencia.dart @@ -0,0 +1,85 @@ +import 'package:mi_utem/config/secure_storage.dart'; +import 'package:mi_utem/utils/utils.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum Preferencia { + apodo, + lastLogin, + onboardingStep, + isOffline(memory: true), + ; + + final bool memory; + + /// Instancia de una preferencia + /// Si memory es falso, entonces la preferencia se guardará en el storage, si es verdadero, se guardará en memoria + const Preferencia({ + this.memory = false + }); + + /// Revisa si la preferencia existe + Future exists() async { + if(!memory) { + return await secureStorage.containsKey(key: this.name); + } + + final SharedPreferences prefs = await SharedPreferences.getInstance(); + return prefs.containsKey(this.name); + } + + /// Obtiene la preferencia del storage, pero si no existe retorna el valor por defecto + /// Si [guardar] es verdadero, entonces si no existe la preferencia, se guardará el valor por defecto + Future get({ String? defaultValue, bool guardar = false }) async { + if(defaultValue != null) { + await add(defaultValue); + } + + if(!memory) { + return await secureStorage.read(key: this.name) ?? defaultValue; + } + + final SharedPreferences prefs = await SharedPreferences.getInstance(); + return prefs.getString(this.name) ?? defaultValue; + } + + /// Obtiene la preferencia del storage, pero si no existe retorna el valor por defecto + /// Si [guardar] es verdadero, entonces si no existe la preferencia, se guardará el valor por defecto + /// Retorna un valor booleano + Future getAsBool({ bool defaultValue = false, bool guardar = false }) async => await get(defaultValue: defaultValue.toString(), guardar: guardar) == "true"; + + /// Obtiene la preferencia del storage, pero si no existe retorna el valor por defecto + /// Si [guardar] es verdadero, entonces si no existe la preferencia, se guardará el valor por defecto + /// Retorna un valor entero + Future getAsNum({ int defaultValue = 0, bool guardar = false }) async => let(await get(defaultValue: defaultValue.toString(), guardar: guardar), (data) => num.tryParse(data)); + + /// Guarda la preferencia en el storage + set(String value) async { + if(!memory) { + await secureStorage.write(key: this.name, value: value); + return; + } + + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString(this.name, value); + } + + /// Guarda la preferencia en el storage solo si no existe + /// Si la preferencia ya existe, no se guardará nada + add(String value) async { + if (!(await exists())) { + await set(value); + } + } + + /// Elimina la preferencia del storage + delete() async { + if(!memory) { + await secureStorage.delete(key: this.name); + return; + } + + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.remove(this.name); + } + +} \ No newline at end of file diff --git a/lib/models/rut.dart b/lib/models/rut.dart deleted file mode 100644 index c477d7b..0000000 --- a/lib/models/rut.dart +++ /dev/null @@ -1,89 +0,0 @@ -class Rut { - int? numero; - String? dv; - - Rut(this.numero, this.dv); - - Rut.deEntero(int? rut) { - this.numero = rut; - this.dv = calcularDv(rut); - } - - Rut.deString(String? rut) { - List separated = separarRutYDv(rut); - this.numero = separated[0] != null ? int.tryParse(separated[0]!) : null; - this.dv = separated[1]; - } - - static List separarRutYDv(String? rutCompleto) { - if (rutCompleto != null) { - String limpio = limpiar(rutCompleto); - if (limpio.length == 0) { - return [null, null]; - } - if (limpio.length == 1) { - return [limpio, null]; - } - String dv = limpio.substring(limpio.length - 1); - String rut = limpio.substring(0, limpio.length - 1); - return [rut, dv]; - } - return [null, null]; - } - - static String? calcularDv(int? numero) { - if (numero != null) { - int suma = 0; - int multiplicador = 2; - String rut = numero.toString(); - for (var i = rut.length - 1; i >= 0; i--) { - String charDigito = String.fromCharCode(rut.runes.elementAt(i)); - suma = suma + int.parse(charDigito) * multiplicador; - multiplicador = multiplicador >= 7 ? 2 : multiplicador + 1; - } - - int valor = 11 - (suma % 11); - - switch (valor) { - case 10: - return "K"; - case 11: - return "0"; - default: - return valor.toString(); - } - } - return null; - } - - bool esValido() { - return this.dv == calcularDv(this.numero); - } - - String formateado(bool conSeparadorDeMiles) { - if (conSeparadorDeMiles) { - String numeroStr = this.numero.toString(); - String numeroFinal = ""; - int contador = 0; - for (var i = (numeroStr.runes.length - 1); i >= 0; i--) { - var char = new String.fromCharCode(numeroStr.runes.elementAt(i)); - contador++; - numeroFinal += char; - if (contador == 3) { - numeroFinal += "."; - contador = 0; - } - } - - numeroFinal = - new String.fromCharCodes(numeroFinal.runes.toList().reversed); - return "$numeroFinal-${this.dv}".toUpperCase(); - } else { - return "${this.numero}-${this.dv}".toUpperCase(); - } - } - - static String limpiar(String rut) { - return rut.replaceAll(new RegExp(r'^0+|[^0-9kK]+'), '').toUpperCase(); - } -} diff --git a/lib/models/user/credential.dart b/lib/models/user/credential.dart new file mode 100644 index 0000000..c9fcb17 --- /dev/null +++ b/lib/models/user/credential.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +class Credentials { + + final String email; + final String password; + + const Credentials({ + required this.email, + required this.password, + }); + + toJson() => { + 'correo': email, + 'contrasenia': password, + }; + + @override + String toString() => jsonEncode(toJson()); + + factory Credentials.fromJson(Map json) => Credentials( + email: json['correo'] as String, + password: json['contrasenia'] as String, + ); +} \ No newline at end of file diff --git a/lib/models/user/persona.dart b/lib/models/user/persona.dart new file mode 100644 index 0000000..c0afb10 --- /dev/null +++ b/lib/models/user/persona.dart @@ -0,0 +1,28 @@ +import 'package:mi_utem/models/user/rut.dart'; +import 'package:mi_utem/utils/string_utils.dart'; + +class Persona { + + final String nombreCompleto; + final Rut? rut; + + const Persona({ + required this.nombreCompleto, + this.rut + }); + + String get nombreCompletoCapitalizado => capitalize(nombreCompleto.trim()); + String get primerNombre => nombreCompletoCapitalizado.split(' ')[0]; + String get iniciales => nombreCompletoCapitalizado.split(' ').map((it) => it[0]).join(''); + + factory Persona.fromJson(Map json) => Persona( + nombreCompleto: json['nombreCompleto'], + rut: json.containsKey("rut") ? Rut.fromString("${json['rut']}") : null, + ); + + Map toJson() => { + 'nombreCompleto': nombreCompleto, + 'rut': rut?.rut, + }; + +} \ No newline at end of file diff --git a/lib/models/user/rut.dart b/lib/models/user/rut.dart new file mode 100644 index 0000000..685739d --- /dev/null +++ b/lib/models/user/rut.dart @@ -0,0 +1,53 @@ +class Rut { + int rut; + + get dv => _calcularDV(); + + Rut(this.rut); + + String _calcularDV() { + var rutString = rut.toString(); + var suma = 0; + var multiplo = 2; + for (var i = rutString.length - 1; i >= 0; i--) { + suma += int.parse(rutString[i]) * multiplo; + multiplo++; + if (multiplo > 7) { + multiplo = 2; + } + } + + final dv = 11 - (suma % 11); + return dv == 11 ? "0" : (dv == 10 ? "K" : dv.toString()); + } + + static Rut fromString(String rut) { + if(rut.contains("-")) { + rut = rut.split("-")[0]; + } + + return Rut(int.parse(rut.replaceAll(".", ""))); + } + + @override + String toString() { + final rut = "${this.rut}${dv.toUpperCase()}"; + if (rut.isEmpty) { + return rut; + } + var rutLength = rut.length; + var verificationDigit = '-${rut.substring(rutLength - 1)}'; + + var result = ''; + for (var i = 1; i < rutLength; i += 1) { + var start = rutLength - i - 1; + var end = rutLength - i; + result = '${rut.substring(start, end)}$result'; + if (i % 3 == 0) { + result = '.$result'; + } + } + return '$result$verificationDigit'; + } + +} \ No newline at end of file diff --git a/lib/models/user/user.dart b/lib/models/user/user.dart new file mode 100644 index 0000000..ca0bc41 --- /dev/null +++ b/lib/models/user/user.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; + +import 'package:mi_utem/models/user/persona.dart'; +import 'package:mi_utem/models/user/rut.dart'; +import 'package:mi_utem/utils/string_utils.dart'; + +class User extends Persona { + + String? token; + + String? correoPersonal; + String? correoUtem; + + String? fotoBase64; + List perfiles; + + String? nombres; + String? apellidos; + + String? username; + String? fotoUrl; + + get nombreDisplayCapitalizado => capitalize("${nombres?.split(' ')[0]} $apellidos"); + + User({ + super.rut, + super.nombreCompleto = "N/N", + this.token, + this.correoPersonal = "N/N", + this.correoUtem, + this.fotoBase64, + this.perfiles = const [], + this.nombres, + this.apellidos, + this.username, + this.fotoUrl + }); + + static List fromJsonList(List? list) => list != null ? list.map((json) => User.fromJson(json as Map)).toList() : []; + + factory User.fromJson(Map json) => User( + rut: json.containsKey('rut') ? Rut.fromString("${json['rut']}") : null, + nombreCompleto: json['nombreCompleto'], + token: json['token'], + correoPersonal: json['correoPersonal'], + correoUtem: json['correoUtem'], + fotoBase64: json['fotoBase64'], + perfiles: ((json['perfiles'] as List?) ?? []).map((it) => it.toString()).toList(), + nombres: json['nombres'], + apellidos: json['apellidos'], + username: json['username'], + fotoUrl: json['fotoUrl'], + ); + + Map toJson() => { + 'token': token, + 'rut': rut?.rut, + 'correoPersonal': correoPersonal, + 'correoUtem': correoUtem, + 'fotoBase64': fotoBase64, + 'perfiles': perfiles, + 'nombreCompleto': nombreCompleto, + 'nombres': nombres, + 'apellidos': apellidos, + 'username': username, + 'fotoUrl': fotoUrl + }; + + @override + String toString() => jsonEncode(toJson()); + + User copyWith({ + Rut? rut, + String? nombreCompleto, + String? token, + String? correoPersonal, + String? correoUtem, + String? fotoBase64, + List? perfiles, + String? nombres, + String? apellidos, + String? username, + String? fotoUrl + }) => User( + rut: rut ?? this.rut, + nombreCompleto: nombreCompleto ?? this.nombreCompleto, + token: token ?? this.token, + correoPersonal: correoPersonal ?? this.correoPersonal, + correoUtem: correoUtem ?? this.correoUtem, + fotoBase64: fotoBase64 ?? this.fotoBase64, + perfiles: perfiles ?? this.perfiles, + nombres: nombres ?? this.nombres, + apellidos: apellidos ?? this.apellidos, + username: username ?? this.username, + fotoUrl: fotoUrl ?? this.fotoUrl + ); +} \ No newline at end of file diff --git a/lib/models/usuario.dart b/lib/models/usuario.dart deleted file mode 100644 index 66d0dd3..0000000 --- a/lib/models/usuario.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:mi_utem/models/rut.dart'; -import 'package:recase/recase.dart'; - -class Usuario { - String? nombre; - String? nombres; - String? apellidos; - String? correoUtem; - String? correoPersonal; - String? token; - String? fotoUrl; - Rut? rut; - - Usuario( - {this.correoUtem, - this.correoPersonal, - this.token, - this.nombres, - this.nombre, - this.fotoUrl, - this.apellidos, - this.rut}); - - factory Usuario.fromJson(Map? json) { - if (json == null) { - return Usuario(); - } - return Usuario( - rut: json['rut'] != null - ? (json['rut'] is int - ? Rut.deEntero(json['rut']) - : Rut.deString(json["rut"])) - : null, - correoUtem: json['correoUtem'], - correoPersonal: json['correoPersonal'], - token: json['token'], - fotoUrl: json['fotoUrl'], - nombres: - json['nombres'] != null ? ReCase(json['nombres']).titleCase : null, - nombre: json['nombreCompleto'] != null - ? ReCase(json['nombreCompleto']).titleCase - : null, - apellidos: json['apellidos'] != null - ? ReCase(json['apellidos']).titleCase - : null); - } - - static List fromJsonList(dynamic json) { - if (json == null) { - return []; - } - List list = []; - for (var item in json) { - list.add(Usuario.fromJson(item)); - } - return list; - } - - String? get nombreCompleto { - if (nombre != null && nombre!.isNotEmpty) { - return nombre; - } else { - String completo = ''; - if (nombres != null && nombres!.isNotEmpty) { - completo += nombres!; - } - if (apellidos != null && apellidos!.isNotEmpty) { - if (completo.isNotEmpty) { - completo += ' '; - } - completo += apellidos!; - } - return completo; - } - } - - String get primerNombre { - return nombreCompleto!.split(' ')[0]; - } - - String get iniciales => primeraLetraCadaPalabra(this.nombreCompleto); - - String primeraLetraCadaPalabra(String? sentence) { - if (sentence == null || sentence.isEmpty) { - return "NN"; - } else { - List words = sentence.split(" "); - List letters = []; - - for (var word in words) { - letters.add('${word[0]}'); - } - return letters.join(""); - } - } - - Map toJson() { - return { - 'nombreCompleto': nombreCompleto, - 'correoUtem': correoUtem, - 'correoPersonal': correoPersonal, - 'token': token, - 'fotoUrl': fotoUrl, - 'nombres': nombres, - 'apellidos': apellidos, - 'rut': rut?.toString(), - }; - } -} diff --git a/lib/repositories/asignaturas_repository.dart b/lib/repositories/asignaturas_repository.dart new file mode 100644 index 0000000..3fe8b28 --- /dev/null +++ b/lib/repositories/asignaturas_repository.dart @@ -0,0 +1,36 @@ +import 'package:mi_utem/models/asignaturas/asignatura.dart'; +import 'package:mi_utem/models/user/user.dart'; +import 'package:mi_utem/utils/http/functions.dart'; +import 'package:mi_utem/utils/utils.dart'; + +class AsignaturasRepository { + + /// Obtiene las asignaturas de una carrera + Future?> getAsignaturas(String? carreraId, {bool forceRefresh = false}) async { + if(carreraId == null) { + return null; + } + + final response = await authClientRequest("carreras/$carreraId/asignaturas", forceRefresh: forceRefresh); + return Asignatura.fromJsonList(response.data); + } + + /// Obtiene los estudiantes de una asignatura + Future?> getEstudiantesAsignatura(Asignatura? asignatura, {bool forceRefresh = false}) async { + if(asignatura == null) { + return null; + } + + final response = await authClientRequest("asignaturas/${asignatura.codigo}", forceRefresh: forceRefresh); + final json = response.data as Map; + + final estudiantes = (json['estudiantes'] as List).map((it) { + it['nombreCompleto'] = (it['nombreCompleto'] as String).split(" ").rotate(2).join(" "); + return it; + }).toList(); + + return User.fromJsonList(estudiantes); + } + + +} \ No newline at end of file diff --git a/lib/repositories/auth_repository.dart b/lib/repositories/auth_repository.dart new file mode 100644 index 0000000..6492e4c --- /dev/null +++ b/lib/repositories/auth_repository.dart @@ -0,0 +1,34 @@ +import 'package:dio/dio.dart'; +import 'package:mi_utem/config/constants.dart'; +import 'package:mi_utem/models/user/credential.dart'; +import 'package:mi_utem/models/user/user.dart'; +import 'package:mi_utem/utils/http/functions.dart'; +import 'package:mi_utem/utils/http/http_client.dart'; + +class AuthRepository { + + final _httpClient = HttpClient.httpClient; + + Future auth({required Credentials credentials}) async { + final response = await _httpClient.post("$apiUrl/v1/auth", data: credentials.toJson()); + return User.fromJson(response.data as Map); + } + + Future refresh({required String token, required Credentials credentials}) async { + final response = await _httpClient.post("$apiUrl/v1/auth/refresh", data: credentials.toJson(), options: Options( + headers: { + "Authorization": "Bearer $token", + }, + )); + final json = response.data as Map; + return json["token"]; + } + + Future updateProfilePicture({required String image}) async { + final response = await authClientRequest("usuarios/foto", method: "PUT", data: ({"imagen": image})); + final json = response.data; + + return json["fotoUrl"] as String; + } + +} \ No newline at end of file diff --git a/lib/repositories/carreras_repository.dart b/lib/repositories/carreras_repository.dart new file mode 100644 index 0000000..05289e2 --- /dev/null +++ b/lib/repositories/carreras_repository.dart @@ -0,0 +1,14 @@ +import 'package:mi_utem/models/carrera.dart'; +import 'package:mi_utem/utils/http/functions.dart'; + +class CarrerasRepository { + + Future> getCarreras({ bool forceRefresh = false }) async { + final response = await authClientRequest("carreras", + forceRefresh: forceRefresh, + ttl: Duration(days: 60), + ); + return Carrera.fromJsonList(response.data as List); + } + +} \ No newline at end of file diff --git a/lib/repositories/credentials_repository.dart b/lib/repositories/credentials_repository.dart new file mode 100644 index 0000000..7bf7a6d --- /dev/null +++ b/lib/repositories/credentials_repository.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:mi_utem/config/secure_storage.dart'; +import 'package:mi_utem/models/user/credential.dart'; + +class CredentialsRepository { + + Future getCredentials() async { + final data = await secureStorage.read(key: "credentials"); + if(data == null || data == "null") { + return null; + } + + return Credentials.fromJson(jsonDecode(data) as Map); + } + + Future hasCredentials() async => await secureStorage.containsKey(key: "credentials"); + + Future setCredentials(Credentials? credential) async => await secureStorage.write(key: "credentials", value: credential != null ? credential.toString() : null); + +} \ No newline at end of file diff --git a/lib/repositories/grades_repository.dart b/lib/repositories/grades_repository.dart new file mode 100644 index 0000000..30fc7de --- /dev/null +++ b/lib/repositories/grades_repository.dart @@ -0,0 +1,14 @@ +import 'package:mi_utem/models/evaluacion/grades.dart'; +import 'package:mi_utem/utils/http/functions.dart'; + +class GradesRepository { + + Future getGrades({required String? carreraId, required String? asignaturaId, bool forceRefresh = false}) async { + if(carreraId == null || asignaturaId == null) { + return null; + } + + final response = await authClientRequest("carreras/$carreraId/asignaturas/$asignaturaId/notas", forceRefresh: forceRefresh); + return Grades.fromJson(response.data as Map); + } +} \ No newline at end of file diff --git a/lib/repositories/horario_repository.dart b/lib/repositories/horario_repository.dart new file mode 100644 index 0000000..10fe3bd --- /dev/null +++ b/lib/repositories/horario_repository.dart @@ -0,0 +1,13 @@ +import 'package:mi_utem/models/horario.dart'; +import 'package:mi_utem/utils/http/functions.dart'; + +class HorarioRepository { + + Future getHorario(String carreraId, {bool forceRefresh = false}) async { + final response = await authClientRequest("carreras/$carreraId/horarios", + ttl: const Duration(days: 14), + forceRefresh: forceRefresh, + ); + return Horario.fromJson(response.data); + } +} diff --git a/lib/repositories/noticias_repository.dart b/lib/repositories/noticias_repository.dart new file mode 100644 index 0000000..977ce0e --- /dev/null +++ b/lib/repositories/noticias_repository.dart @@ -0,0 +1,44 @@ +import 'package:dio/dio.dart'; +import 'package:dio_http_cache/dio_http_cache.dart'; +import 'package:mi_utem/models/noticia.dart'; +import 'package:mi_utem/utils/http/http_client.dart'; +import 'package:mi_utem/utils/http/interceptors/headers_interceptor.dart'; +import 'package:mi_utem/utils/http/interceptors/log_interceptor.dart'; + +class NoticiasRepository { + + final _httpClient = Dio(BaseOptions(baseUrl: "https://noticias.utem.cl"))..interceptors.addAll([ + HeadersInterceptor(), + logInterceptor, + HttpClient.cacheManager.interceptor, + ]); + + Future> getNoticias({ bool forceRefresh = false }) async { + final hasta = DateTime.now().toUtc().toIso8601String(); + final desde = DateTime.now().subtract(Duration(days: 180)).toUtc().toIso8601String(); + final categoryIdResponse = await _httpClient.get("/wp-json/wp/v2/categories", + options: buildCacheOptions(Duration(days: 14), forceRefresh: forceRefresh, subKey: "/noticias/categorias"), + queryParameters: { + "_fields": "id", + "slug": "todas-las-noticias", + }, + ); + final categoryId = ((categoryIdResponse.data as List).first as Map)['id']; + final response = await _httpClient.get("/wp-json/wp/v2/posts", + options: buildCacheOptions(Duration(days: 14), forceRefresh: forceRefresh, subKey: "/noticias"), + queryParameters: { + "_fields": ["id", "yoast_head_json.title", "yoast_head_json.og_description", "yoast_head_json.og_image"].join(','), + "categories": categoryId, + "per_page": 12, + "before": hasta, + "after": desde, + }, + ); + if (response.statusCode != 200) { + return []; + } + + return Noticia.fromJsonList(response.data as List); + } + +} \ No newline at end of file diff --git a/lib/repositories/permiso_ingreso_repository.dart b/lib/repositories/permiso_ingreso_repository.dart new file mode 100644 index 0000000..091ffb7 --- /dev/null +++ b/lib/repositories/permiso_ingreso_repository.dart @@ -0,0 +1,25 @@ +import 'package:mi_utem/models/permiso_ingreso.dart'; +import 'package:mi_utem/utils/http/functions.dart'; + +class PermisoIngresoRepository { + + Future> getPermisos({ bool forceRefresh = false }) async { + final response = await authClientRequest("permisos", + method: "POST", + ttl: Duration(days: 30), + forceRefresh: forceRefresh, + ); + return PermisoIngreso.fromJsonList(response.data as List?); + } + + Future getDetallesPermiso(String id, { bool forceRefresh = false }) async { + final response = await authClientRequest("permisos/$id", + method: "POST", + ttl: Duration(days: 30), + forceRefresh: forceRefresh, + ); + return PermisoIngreso.fromJson(response.data as Map?); + } + + +} \ No newline at end of file diff --git a/lib/screens/acerca_screen.dart b/lib/screens/acerca_screen.dart new file mode 100644 index 0000000..b7ac294 --- /dev/null +++ b/lib/screens/acerca_screen.dart @@ -0,0 +1,41 @@ + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:mi_utem/widgets/acerca/club/acerca_app.dart'; +import 'package:mi_utem/widgets/acerca/club/acerca_club.dart'; +import 'package:mi_utem/widgets/acerca/club/acerca_club_desarrolladores.dart'; +import 'package:mi_utem/widgets/acerca/dialog/acerca_aplicacion_content.dart'; +import 'package:mi_utem/widgets/custom_app_bar.dart'; + +class AcercaScreen extends StatelessWidget { + const AcercaScreen({ + super.key, + }); + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: Colors.grey[200], + appBar: CustomAppBar( + title: const Text("Acerca de Mi UTEM"), + ), + body: SafeArea(child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const AcercaClub(), + Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), + child: AcercaAplicacionContent(), + ), + const AcercaClubDesarrolladores(), + kDebugMode ? const AcercaApp() : const SizedBox(), + ], + ), + ), + )), + ); +} diff --git a/lib/screens/asignatura/asignaturas_lista_screen.dart b/lib/screens/asignatura/asignaturas_lista_screen.dart new file mode 100644 index 0000000..ecb9af9 --- /dev/null +++ b/lib/screens/asignatura/asignaturas_lista_screen.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mdi/mdi.dart'; +import 'package:mi_utem/config/logger.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; +import 'package:mi_utem/models/carrera.dart'; +import 'package:mi_utem/models/exceptions/custom_exception.dart'; +import 'package:mi_utem/models/pair.dart'; +import 'package:mi_utem/repositories/asignaturas_repository.dart'; +import 'package:mi_utem/repositories/grades_repository.dart'; +import 'package:mi_utem/screens/calculadora_notas_screen.dart'; +import 'package:mi_utem/services/carreras_service.dart'; +import 'package:mi_utem/services/remote_config/remote_config.dart'; +import 'package:mi_utem/widgets/asignatura/lista/lista_asignaturas.dart'; +import 'package:mi_utem/widgets/asignatura/lista/sin_asignaturas_mensaje.dart'; +import 'package:mi_utem/widgets/custom_app_bar.dart'; +import 'package:mi_utem/widgets/loading/loading_indicator.dart'; +import 'package:mi_utem/widgets/pull_to_refresh.dart'; + +class AsignaturasListaScreen extends StatefulWidget { + + const AsignaturasListaScreen({ + super.key, + }); + + @override + State createState() => _AsignaturasListaScreenState(); +} + +class _AsignaturasListaScreenState extends State { + + final _asignaturasService = Get.find(); + bool _forceRefresh = false; + + bool get _mostrarCalculadora => RemoteConfigService.calculadoraMostrar; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: CustomAppBar( + title: Text("Asignaturas"), + actions: _mostrarCalculadora ? [ + IconButton( + icon: Icon(Mdi.calculator), + tooltip: "Calculadora", + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (ctx) => CalculadoraNotasScreen())), + ), + ] : [], + ), + body: SafeArea(child: PullToRefresh( + onRefresh: () async => setState(() => _forceRefresh = true), + child: FutureBuilder?>>( + future: () async { + final carrera = await Get.find().getCarreras(); + if(carrera == null) { + _forceRefresh = false; + throw CustomException.custom("No pudimos cargar los datos de tu carrera."); + } + final asignaturas = await _asignaturasService.getAsignaturas(carrera.id, forceRefresh: _forceRefresh); + if(asignaturas == null) { + _forceRefresh = false; + throw CustomException.custom("No pudimos cargar las asignaturas."); + } + _forceRefresh = false; + asignaturas.forEach((asignatura) => Get.find().getGrades(carreraId: carrera.id, asignaturaId: asignatura.id)); + return Pair(carrera, asignaturas); + }(), + builder: (context, snapshot) { + if(snapshot.hasError) { + logger.e(snapshot.error); + final error = snapshot.error is CustomException ? (snapshot.error as CustomException).message : "Ocurrió un error al obtener las asignaturas"; + return _errorWidget(error); + } + + if(snapshot.connectionState == ConnectionState.waiting) { + return _loadingWidget(); + } + + final datos = snapshot.data; + final carrera = datos?.a; + final asignaturas = datos?.b; + if(carrera == null || asignaturas == null) { + return _errorWidget("No encontramos asignaturas. Por favor intenta más tarde."); + } + + return ListaAsignaturas( + carrera: carrera, + asignaturas: asignaturas, + ); + }, + ), + )), + ); + + Widget _loadingWidget() => Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: LoadingIndicator.centered(), + ), + ], + ), + ); + + Widget _errorWidget(String mensaje) => Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Center( + child: SinAsignaturasMensaje(mensaje: mensaje, emoji: "\u{1F622}"), + ), + ), + ], + ), + ); +} diff --git a/lib/screens/asignatura/detalle/asignatura_detalle_screen.dart b/lib/screens/asignatura/detalle/asignatura_detalle_screen.dart new file mode 100644 index 0000000..0557243 --- /dev/null +++ b/lib/screens/asignatura/detalle/asignatura_detalle_screen.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mdi/mdi.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; +import 'package:mi_utem/models/asignaturas/detalles/navigation_tab.dart'; +import 'package:mi_utem/models/carrera.dart'; +import 'package:mi_utem/repositories/grades_repository.dart'; +import 'package:mi_utem/screens/asignatura/detalle/asignatura_notas_tab.dart'; +import 'package:mi_utem/screens/asignatura/detalle/asignatura_resumen_tab.dart'; +import 'package:mi_utem/screens/calculadora_notas_screen.dart'; +import 'package:mi_utem/services/remote_config/remote_config.dart'; +import 'package:mi_utem/services/review_service.dart'; +import 'package:mi_utem/widgets/custom_app_bar.dart'; + +class AsignaturaDetalleScreen extends StatefulWidget { + final Carrera carrera; + final Asignatura asignatura; + + const AsignaturaDetalleScreen({ + super.key, + required this.carrera, + required this.asignatura, + }); + + @override + State createState() => _AsignaturaDetalleScreenState(asignatura: asignatura); +} + +class _AsignaturaDetalleScreenState extends State { + + Asignatura asignatura; + + _AsignaturaDetalleScreenState({ + required this.asignatura, + }); + + @override + Widget build(BuildContext context) { + ReviewService.addScreen("AsignaturaScreen"); + + final tabs = [ + NavigationTab( + label: "Resumen", + child: AsignaturaResumenTab(asignatura: asignatura), + ), + NavigationTab( + label: "Notas", + child: AsignaturaNotasTab( + asignatura: asignatura, + onRefresh: () async { + final grades = await Get.find().getGrades(carreraId: widget.carrera.id, asignaturaId: this.asignatura.id, forceRefresh: true); + setState(() => this.asignatura = asignatura.copyWith(grades: grades)); + }, + ), + initial: true, + ), + ]; + + return DefaultTabController( + initialIndex: tabs.indexWhere((it) => it.initial), + length: tabs.length, + child: Scaffold( + appBar: CustomAppBar( + title: Text(asignatura.nombre), + actions: RemoteConfigService.calculadoraMostrar ? [ + IconButton( + icon: Icon(Mdi.calculator), + tooltip: "Calculadora", + onPressed: _onTapCalculadora, + ), + ] : [], + bottom: TabBar( + indicatorColor: Colors.white.withOpacity(0.8), + tabs: tabs.map((e) => Tab(text: e.label)).toList(), + ), + ), + body: SafeArea(child: TabBarView(children: tabs.map((e) => e.child).toList())), + ), + ); + } + + _onTapCalculadora() async { + final grades = await Get.find().getGrades(carreraId: widget.carrera.id, asignaturaId: asignatura.id); + Navigator.push(context, MaterialPageRoute(builder: (ctx) => CalculadoraNotasScreen(grades: grades))); + } +} + diff --git a/lib/screens/asignatura/detalle/asignatura_estudiantes_tab.dart b/lib/screens/asignatura/detalle/asignatura_estudiantes_tab.dart new file mode 100644 index 0000000..d6701fc --- /dev/null +++ b/lib/screens/asignatura/detalle/asignatura_estudiantes_tab.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; +import 'package:mi_utem/models/exceptions/custom_exception.dart'; +import 'package:mi_utem/models/user/user.dart'; +import 'package:mi_utem/repositories/asignaturas_repository.dart'; +import 'package:mi_utem/services/analytics_service.dart'; +import 'package:mi_utem/widgets/asignatura/modals/user_modal.dart'; +import 'package:mi_utem/widgets/custom_app_bar.dart'; +import 'package:mi_utem/widgets/custom_error_widget.dart'; +import 'package:mi_utem/widgets/loading/loading_indicator.dart'; +import 'package:mi_utem/widgets/pull_to_refresh.dart'; + + +class AsignaturaEstudiantesTab extends StatefulWidget { + final Asignatura? asignatura; + + const AsignaturaEstudiantesTab({ + super.key, + this.asignatura, + }); + + @override + State createState() => _AsignaturaEstudiantesTabState(); +} + +class _AsignaturaEstudiantesTabState extends State { + + bool _forceRefresh = false; + AsignaturasRepository _asignaturasRepository = Get.find(); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: CustomAppBar( + title: Text("Estudiantes"), + ), + body: SafeArea(child: PullToRefresh( + onRefresh: () async => setState(() => _forceRefresh = true), + child: FutureBuilder?>( + future: () async { + final estudiantes = await _asignaturasRepository.getEstudiantesAsignatura(widget.asignatura, forceRefresh: _forceRefresh); + _forceRefresh = false; + return estudiantes; + }(), + builder: (ctx, snapshot) { + if(snapshot.connectionState == ConnectionState.waiting) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Center( + child: LoadingIndicator(), + ), + ), + ], + ), + ); + } + + List? estudiantes = snapshot.data; + if(snapshot.hasError || !snapshot.hasData || estudiantes == null) { + return Center( + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: CustomErrorWidget( + emoji: "\u{1F622}", + title: (snapshot.error is CustomException ? (snapshot.error as CustomException).message : "Ocurrió un error al obtener los estudiantes"), + ), + ), + ); + } + + return ListView.separated( + itemCount: estudiantes.length, + separatorBuilder: (context, index) => Divider( + height: 5, + indent: 20, + endIndent: 20, + ), + itemBuilder: (context, i) => ListTile( + title: Text(estudiantes[i].nombreCompletoCapitalizado), + subtitle: Text(estudiantes[i].correoUtem ?? ''), + onTap: () { + AnalyticsService.logEvent('asignatura_estudiante_tap', parameters: { + 'asignatura': widget.asignatura?.codigo, + 'estudiante': estudiantes[i].correoUtem, + }); + showModalBottomSheet(context: context, builder: (ctx) => UserModal( + user: estudiantes[i], + )); + }, + ), + ); + }, + ), + )), + ); +} diff --git a/lib/screens/asignatura/detalle/asignatura_notas_tab.dart b/lib/screens/asignatura/detalle/asignatura_notas_tab.dart new file mode 100644 index 0000000..256b56a --- /dev/null +++ b/lib/screens/asignatura/detalle/asignatura_notas_tab.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; +import 'package:mi_utem/models/evaluacion/evaluacion.dart'; +import 'package:mi_utem/widgets/asignatura/notas_tab/nota_list_item.dart'; +import 'package:mi_utem/widgets/asignatura/notas_tab/notas_display.dart'; +import 'package:mi_utem/widgets/custom_error_widget.dart'; +import 'package:mi_utem/widgets/pull_to_refresh.dart'; + + +class AsignaturaNotasTab extends StatelessWidget { + final Asignatura asignatura; + final Future Function() onRefresh; + + const AsignaturaNotasTab({ + super.key, + required this.asignatura, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + final grades = asignatura.grades; + final notasParciales = asignatura.grades?.notasParciales; + if(grades == null || notasParciales == null || notasParciales.isEmpty) { + return const CustomErrorWidget( + emoji: "🤔", + title: "Parece que aún no hay notas ni ponderadores", + ); + } + + return PullToRefresh( + onRefresh: onRefresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(10), + children: [ + NotasDisplayWidget( + notaFinal: grades.notaFinal, + notaExamen: grades.notaExamen, + notaPresentacion: grades.notaPresentacion, + estado: asignatura.estado, + colorPorEstado: asignatura.colorPorEstado, + ), + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: ListView.builder( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + itemBuilder: (context, i) => NotaListItem(evaluacion: IEvaluacion.fromRemote(notasParciales[i])), + itemCount: notasParciales.length, + ), + ), + ), + ], + ), + ); + } +} + + diff --git a/lib/screens/asignatura/detalle/asignatura_resumen_tab.dart b/lib/screens/asignatura/detalle/asignatura_resumen_tab.dart new file mode 100644 index 0000000..08083b1 --- /dev/null +++ b/lib/screens/asignatura/detalle/asignatura_resumen_tab.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; +import 'package:mi_utem/screens/asignatura/detalle/asignatura_estudiantes_tab.dart'; +import 'package:mi_utem/utils/string_utils.dart'; +import 'package:mi_utem/widgets/field_list_tile.dart'; +import 'package:mi_utem/widgets/modals/persona_modal.dart'; + +class AsignaturaResumenTab extends StatelessWidget { + final Asignatura asignatura; + + const AsignaturaResumenTab({ + super.key, + required this.asignatura, + }); + + List>? get datosHorario => asignatura.horario?.split("||").map((e) => e.trim()).map((it) => { + 'dia': capitalize(it.split("|")[0]).trim(), + 'horas': it.split("|")[1].split("/").map((it) => it.trim()).map((it) => "- $it").join("\n"), + }).toList(); + + @override + Widget build(BuildContext context) => SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + child: Container( + child: ListView( + padding: EdgeInsets.only(top: 20.0, bottom: 5.0), + shrinkWrap: true, + physics: ScrollPhysics(), + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 15), + width: double.infinity, + child: Text("Asignatura".toUpperCase(), + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.left, + ), + ), + GestureDetector( + child: FieldListTile( + title: "Docente", + value: asignatura.docente.nombreCompleto, + ), + onTap: () async => showModalBottomSheet(context: context, builder: (ctx) => PersonaModal(persona: asignatura.docente)), + ), + if (asignatura.seccion.isNotEmpty) ...[ + Divider(height: 5, indent: 20, endIndent: 20), + FieldListTile( + title: "Sección", + value: asignatura.seccion.toString(), + ), + ], + Divider(height: 5, indent: 20, endIndent: 20), + FieldListTile( + title: "Código Asignatura", + value: asignatura.codigo, + ), + if (asignatura.tipoAsignatura != null) ...[ + Divider(height: 5, indent: 20, endIndent: 20), + FieldListTile( + title: "Tipo de Asignatura", + value: asignatura.tipoAsignatura, + ), + ], + if (asignatura.intentos != null) ...[ + Divider(height: 5, indent: 20, endIndent: 20), + FieldListTile( + title: "Intentos", + value: asignatura.intentos.toString(), + ), + Divider(height: 5, indent: 20, endIndent: 20), + ], + FieldListTile( + title: "Estudiantes", + value: "Presiona para ver los estudiantes", + suffixIcon: Icon(Icons.arrow_forward_ios, size: 15, color: Colors.grey), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (ctx) => AsignaturaEstudiantesTab(asignatura: this.asignatura))); + } + ), + Divider(height: 5), + + Container( + padding: EdgeInsets.symmetric(horizontal: 15), + margin: EdgeInsets.only(top: 20), + width: double.infinity, + child: Text("Sala".toUpperCase(), + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.left, + ), + ), + FieldListTile( + title: "Tipo de hora", + value: asignatura.tipoHora, + ), + Divider(height: 5, indent: 20, endIndent: 20), + FieldListTile( + title: "Sala", + value: asignatura.sala?.split(",").map((it) => "- ${it.trim().replaceAll("-", " - ")}").join("\n"), + ), + + Divider(height: 5), + Container( + padding: EdgeInsets.symmetric(horizontal: 15), + margin: EdgeInsets.only(top: 20), + width: double.infinity, + child: Text("Horario".toUpperCase(), + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.left, + ), + ), + ...datosHorario?.map((it) => Column( + children: [ + FieldListTile( + title: it['dia'] ?? "", + value: it['horas'] + ), + if ("${datosHorario?.last['dia']} ${datosHorario?.last['horas']}" != "${it['dia']} ${it['horas']}") Divider(height: 5, indent: 20, endIndent: 20), + ], + )).toList() ?? [], + ], + ), + ), + ), + ], + ), + ); +} diff --git a/lib/screens/asignatura_detalle_screen.dart b/lib/screens/asignatura_detalle_screen.dart deleted file mode 100644 index 5553079..0000000 --- a/lib/screens/asignatura_detalle_screen.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mdi/mdi.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/controllers/asignatura_controller.dart'; -import 'package:mi_utem/controllers/calculator_controller.dart'; -import 'package:mi_utem/models/asignatura.dart'; -import 'package:mi_utem/screens/asignatura_estudiantes_tab.dart'; -import 'package:mi_utem/screens/asignatura_notas_tab.dart'; -import 'package:mi_utem/screens/asignatura_resumen_tab.dart'; -import 'package:mi_utem/services/remote_config/remote_config.dart'; -import 'package:mi_utem/services/review_service.dart'; -import 'package:mi_utem/widgets/custom_app_bar.dart'; -import 'package:mi_utem/widgets/loading_indicator.dart'; - -class _ITabs { - final String label; - final Widget child; - final bool initial; - - _ITabs({ - required this.label, - required this.child, - this.initial = false, - }); -} - -class AsignaturaDetalleScreen extends GetView { - AsignaturaDetalleScreen({Key? key}) : super(key: key); - - String? get tag => Get.parameters['asignaturaId']; - - List<_ITabs> _getTabs(Asignatura asignatura) => [ - _ITabs( - label: "Resumen", - child: AsignaturaResumenTab(asignatura: asignatura), - ), - _ITabs( - label: "Notas", - child: AsignaturaNotasTab(asignaturaId: asignatura.id!), - initial: true, - ), - if (asignatura.estudiantes != null && - asignatura.estudiantes!.length > 0) - _ITabs( - label: "Estudiantes", - child: AsignaturaEstudiantesTab(asignatura: asignatura), - ), - ]; - - bool get _mostrarCalculadora { - return RemoteConfigService.calculadoraMostrar; - } - - int _getInitialIndex(Asignatura asignatura) { - final index = _getTabs(asignatura).indexWhere((tab) => tab.initial); - return index == -1 ? 0 : index; - } - - @override - Widget build(BuildContext context) { - ReviewService.addScreen("AsignaturaScreen"); - - return controller.obx( - (asignatura) => DefaultTabController( - initialIndex: asignatura != null ? _getInitialIndex(asignatura) : 0, - length: asignatura != null ? _getTabs(asignatura).length : 1, - child: Scaffold( - appBar: CustomAppBar( - title: Text(asignatura?.nombre ?? "Asigntura sin nombre"), - actions: _mostrarCalculadora - ? [ - IconButton( - icon: Icon(Mdi.calculator), - tooltip: "Calculadora", - onPressed: () { - Get.toNamed(Routes.calculadoraNotas); - if (asignatura?.grades != null) { - final calculatorController = CalculatorController.to; - calculatorController.loadGrades(asignatura!.grades!); - } - }, - ), - ] - : [], - bottom: asignatura != null - ? TabBar( - indicatorColor: Colors.white.withOpacity(0.8), - tabs: _getTabs(asignatura) - .map((tab) => Tab(text: tab.label)) - .toList(), - ) - : null, - ), - body: asignatura != null - ? TabBarView( - children: - _getTabs(asignatura).map((tab) => tab.child).toList(), - ) - : Container(), - ), - ), - onLoading: Scaffold( - appBar: CustomAppBar(), - body: Center( - child: LoadingIndicator(), - ), - ), - ); - } -} diff --git a/lib/screens/asignatura_estudiantes_tab.dart b/lib/screens/asignatura_estudiantes_tab.dart deleted file mode 100644 index 76e4ba1..0000000 --- a/lib/screens/asignatura_estudiantes_tab.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:mi_utem/models/asignatura.dart'; -import 'package:mi_utem/services/analytics_service.dart'; -import 'package:mi_utem/widgets/custom_error_widget.dart'; - -class AsignaturaEstudiantesTab extends StatelessWidget { - final Asignatura? asignatura; - - AsignaturaEstudiantesTab({ - Key? key, - this.asignatura, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return asignatura == null - ? CustomErrorWidget( - title: "Ocurrió un error trayendo a los estudiantes", - error: '', - ) - : ListView.separated( - itemCount: 0, - separatorBuilder: (context, index) => Divider( - height: 5, - indent: 20, - endIndent: 20, - ), - itemBuilder: (context, i) { - return ListTile( - onTap: () { - AnalyticsService.logEvent( - 'asignatura_estudiante_tap', - ); - }, - ); - }, - ); - } -} diff --git a/lib/screens/asignatura_notas_tab.dart b/lib/screens/asignatura_notas_tab.dart deleted file mode 100644 index 2d54b63..0000000 --- a/lib/screens/asignatura_notas_tab.dart +++ /dev/null @@ -1,199 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_masked_text/flutter_masked_text.dart'; -import 'package:get/get_state_manager/get_state_manager.dart'; -import 'package:mi_utem/controllers/asignatura_controller.dart'; -import 'package:mi_utem/models/evaluacion.dart'; -import 'package:mi_utem/themes/theme.dart'; -import 'package:mi_utem/widgets/custom_error_widget.dart'; -import 'package:mi_utem/widgets/loading_indicator.dart'; -import 'package:mi_utem/widgets/nota_list_item.dart'; -import 'package:mi_utem/widgets/pull_to_refresh.dart'; - -class AsignaturaNotasTab extends GetView { - final String asignaturaId; - - AsignaturaNotasTab({ - Key? key, - required this.asignaturaId, - }) : super(key: key); - - @override - String get tag => asignaturaId; - - Future _onRefresh() async { - controller.refreshData(); - } - - @override - Widget build(BuildContext context) { - return PullToRefresh( - onRefresh: _onRefresh, - child: controller.obx( - (asignatura) { - final examGradeController = MaskedTextController( - mask: '0.0', - text: asignatura?.grades?.notaExamen?.toStringAsFixed(1) ?? "", - ); - final presentationGradeController = MaskedTextController( - mask: '0.0', - text: - asignatura?.grades?.notaPresentacion?.toStringAsFixed(1) ?? "", - ); - - return ListView( - physics: AlwaysScrollableScrollPhysics(), - padding: EdgeInsets.all(10), - children: [ - Card( - child: Row( - children: [ - Container( - height: 130, - width: 10, - color: asignatura?.colorPorEstado, - ), - Expanded( - child: Container( - padding: EdgeInsets.fromLTRB(15, 20, 20, 20), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - children: [ - Text( - asignatura?.grades?.notaFinal - ?.toStringAsFixed(1) ?? - "S/N", - style: TextStyle( - fontSize: 40, - fontWeight: FontWeight.bold, - ), - ), - Text( - asignatura?.estado ?? "---", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - Container(width: 10), - Container( - height: 80, - width: 0.5, - color: Colors.grey, - ), - Container(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - "Examen", - style: TextStyle(fontSize: 16), - ), - Container( - width: 60, - margin: EdgeInsets.only(left: 15), - child: TextField( - controller: examGradeController, - textAlign: TextAlign.center, - enabled: false, - decoration: InputDecoration( - disabledBorder: MainTheme.theme - .inputDecorationTheme.border! - .copyWith( - borderSide: BorderSide( - color: Colors.transparent, - )), - ), - keyboardType: - TextInputType.numberWithOptions( - decimal: true), - ), - ), - ], - ), - Container(height: 10), - Row( - children: [ - Text( - "Presentación", - style: TextStyle(fontSize: 16), - ), - Container( - width: 60, - margin: EdgeInsets.only(left: 15), - child: TextField( - controller: presentationGradeController, - textAlign: TextAlign.center, - enabled: false, - decoration: InputDecoration( - hintText: "--", - disabledBorder: MainTheme.theme - .inputDecorationTheme.border! - .copyWith( - borderSide: BorderSide( - color: Colors.transparent, - )), - ), - keyboardType: - TextInputType.numberWithOptions( - decimal: true), - ), - ), - ], - ) - ], - ), - ], - ), - ), - ), - ], - ), - ), - Card( - child: Container( - padding: EdgeInsets.all(20), - child: asignatura?.grades?.notasParciales.isNotEmpty == true - ? ListView.builder( - shrinkWrap: true, - physics: ClampingScrollPhysics(), - itemBuilder: (context, i) { - REvaluacion evaluacion = - asignatura!.grades!.notasParciales[i]; - return NotaListItem( - evaluacion: IEvaluacion.fromRemote(evaluacion), - /* onChanged: (evaluacion) { - _controller.chag(evaluacion, nota); - } */ - ); - }, - itemCount: asignatura!.grades!.notasParciales.length, - ) - : CustomErrorWidget( - emoji: "🤔", - title: "Parece que aún no hay notas ni ponderadores", - ), - ), - ), - ], - ); - }, - onLoading: LoadingIndicator(), - onError: (error) => SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: CustomErrorWidget( - title: "Ocurrió un error al cargar las notas", - error: '', - ), - ), - ), - ); - } -} diff --git a/lib/screens/asignatura_resumen_tab.dart b/lib/screens/asignatura_resumen_tab.dart deleted file mode 100644 index 497f019..0000000 --- a/lib/screens/asignatura_resumen_tab.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/models/asignatura.dart'; -import 'package:mi_utem/widgets/custom_error_widget.dart'; -import 'package:mi_utem/widgets/field_list_tile.dart'; - -class AsignaturaResumenTab extends StatelessWidget { - final Asignatura? asignatura; - - AsignaturaResumenTab({ - Key? key, - this.asignatura, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return asignatura == null - ? CustomErrorWidget( - title: "Ocurrió un error al obtener el resumen de la asignatura", - error: '', - ) - : SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Card( - child: Container( - child: ListView( - shrinkWrap: true, - physics: ScrollPhysics(), - children: [ - Container(height: 20), - Container( - padding: EdgeInsets.symmetric(horizontal: 15), - width: double.infinity, - child: Text( - "Resumen".toUpperCase(), - style: Get.textTheme.bodyLarge, - textAlign: TextAlign.left, - ), - ), - FieldListTile( - title: "Nombre", - value: asignatura?.nombre, - ), - Divider(height: 5, indent: 20, endIndent: 20), - FieldListTile( - title: "Código", - value: asignatura?.codigo, - ), - if (asignatura?.seccion != null && - asignatura!.seccion!.isNotEmpty) ...[ - Divider(height: 5, indent: 20, endIndent: 20), - FieldListTile( - title: "Sección", - value: asignatura!.seccion.toString(), - ), - ], - Divider(height: 5, indent: 20, endIndent: 20), - FieldListTile( - title: "Docente", - value: asignatura?.docente ?? "Sin docente", - // trailing: _asignatura.docente != null - // ? Badge( - // shape: BadgeShape.square, - // borderRadius: BorderRadius.circular(10), - // padding: EdgeInsets.symmetric( - // horizontal: 6, vertical: 3), - // elevation: 0, - // badgeContent: Text( - // 'Nuevo', - // style: TextStyle( - // color: Colors.white, - // fontSize: 10, - // fontWeight: FontWeight.bold, - // ), - // ), - // ) - // : null, - // onTap: _asignatura.docente != null - // ? () async { - // await Get.to(() => - // UsuarioScreen( - // tipo: 2, - // query: { - // "nombre": _asignatura.docente - // }, - // asignatura: widget.asignatura, - // ), - // ); - // } - // : null, - ), - Divider(height: 5, indent: 20, endIndent: 20), - if (asignatura?.tipoAsignatura != null) ...[ - FieldListTile( - title: "Tipo de asignatura", - value: asignatura!.tipoAsignatura!, - ), - Divider(height: 5, indent: 20, endIndent: 20), - ], - FieldListTile( - title: "Tipo de hora", - value: asignatura?.tipoHora, - ), - Divider(height: 5, indent: 20, endIndent: 20), - if (asignatura?.horario != null) ...[ - FieldListTile( - title: "Horario", - value: asignatura!.horario!, - ), - Divider(height: 5, indent: 20, endIndent: 20), - ], - FieldListTile( - title: "Intentos", - value: asignatura?.intentos.toString(), - ), - Divider(height: 5, indent: 20, endIndent: 20), - FieldListTile( - title: "Sala", - value: asignatura?.sala, - ), - ], - ), - ), - ), - // Card( - // child: Container( - // padding: EdgeInsets.all(20), - // child: Column( - // children: [ - // Container( - // width: double.infinity, - // child: Text( - // "Asistencia".toUpperCase(), - // style: Get.textTheme.headline4, - // textAlign: TextAlign.left, - // ), - // ), - // AsistenciaChart(asistencia: _asignatura.asistencia), - // ], - // ), - // ), - // ), - ], - ), - ); - } -} diff --git a/lib/screens/asignaturas_lista_screen.dart b/lib/screens/asignaturas_lista_screen.dart deleted file mode 100644 index 4ada864..0000000 --- a/lib/screens/asignaturas_lista_screen.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mdi/mdi.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/controllers/asignaturas_controller.dart'; -import 'package:mi_utem/models/asignatura.dart'; -import 'package:mi_utem/services/remote_config/remote_config.dart'; -import 'package:mi_utem/themes/theme.dart'; -import 'package:mi_utem/widgets/custom_app_bar.dart'; -import 'package:mi_utem/widgets/custom_error_widget.dart'; -import 'package:mi_utem/widgets/loading_indicator.dart'; -import 'package:mi_utem/widgets/pull_to_refresh.dart'; - -class AsignaturasListaScreen extends GetView { - AsignaturasListaScreen({Key? key}) : super(key: key); - - Future _onRefresh() async { - controller.refreshAsignaturas(); - } - - bool get _mostrarCalculadora { - return RemoteConfigService.calculadoraMostrar; - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: CustomAppBar( - title: Text("Asignaturas"), - actions: _mostrarCalculadora - ? [ - IconButton( - icon: Icon(Mdi.calculator), - tooltip: "Calculadora", - onPressed: () { - Get.toNamed( - Routes.calculadoraNotas, - ); - }, - ), - ] - : [], - ), - body: PullToRefresh( - onRefresh: () async { - await _onRefresh(); - }, - child: controller.obx( - (asignaturas) => asignaturas == null || asignaturas.isEmpty - ? Center( - child: SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: CustomErrorWidget( - emoji: "🤔", - title: "Parece que no se encontraron asignaturas", - ), - ), - ) - : ListView.builder( - physics: AlwaysScrollableScrollPhysics(), - itemBuilder: (BuildContext context, int i) { - Asignatura asignatura = asignaturas[i]; - return AsignaturaListTile(asignatura: asignatura); - }, - itemCount: asignaturas.length, - ), - onError: (error) => Center( - child: SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: CustomErrorWidget( - emoji: "🤔", - title: "Ocurrió un error al obtener las asignaturas", - ), - ), - ), - onLoading: Container( - padding: EdgeInsets.all(20), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Center( - child: LoadingIndicator(), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class AsignaturaListTile extends StatelessWidget { - const AsignaturaListTile({ - Key? key, - required this.asignatura, - }) : super(key: key); - - final Asignatura asignatura; - - @override - Widget build(BuildContext context) { - final ThemeData theme = MainTheme.theme; - - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10.0, - ), - child: Card( - child: InkWell( - onTap: () => Get.toNamed('${Routes.asignatura}/${asignatura.id}'), - child: Container( - padding: EdgeInsets.all(20), - width: double.infinity, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - asignatura.nombre!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium, - textAlign: TextAlign.start, - ), - Container(height: 10), - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(asignatura.codigo!), - Text(asignatura.tipoHora!), - ], - ) - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/screens/avancemalla_screen.dart b/lib/screens/avancemalla_screen.dart index a5e59cd..55b6413 100644 --- a/lib/screens/avancemalla_screen.dart +++ b/lib/screens/avancemalla_screen.dart @@ -4,7 +4,6 @@ import 'package:flutter_statusbar/flutter_statusbar.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:mi_utem/models/avance_malla.dart'; -import 'package:mi_utem/models/usuario.dart'; import 'package:mi_utem/services/malla_service.dart'; import 'package:mi_utem/widgets/avance_ramo_card.dart'; diff --git a/lib/screens/boletin_screen.dart b/lib/screens/boletin_screen.dart index b47c37b..1043b61 100644 --- a/lib/screens/boletin_screen.dart +++ b/lib/screens/boletin_screen.dart @@ -3,9 +3,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:mi_utem/models/boletin_notas.dart'; -import 'package:mi_utem/models/usuario.dart'; import 'package:mi_utem/services/boletin_service.dart'; -import 'package:mi_utem/services/horario_service.dart'; +import 'package:mi_utem/services/horario_repository.dart'; import 'package:mi_utem/widgets/custom_expansion_tile.dart' as custom; import 'package:mi_utem/widgets/info_boletin_card.dart'; import 'package:mi_utem/widgets/semestre_boletin_card.dart'; diff --git a/lib/screens/calculadora_notas_screen.dart b/lib/screens/calculadora_notas_screen.dart index 3324e29..a5cc0c7 100644 --- a/lib/screens/calculadora_notas_screen.dart +++ b/lib/screens/calculadora_notas_screen.dart @@ -1,258 +1,59 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:mi_utem/controllers/calculator_controller.dart'; -import 'package:mi_utem/models/evaluacion.dart'; -import 'package:mi_utem/services/analytics_service.dart'; -import 'package:mi_utem/themes/theme.dart'; +import 'package:mi_utem/models/evaluacion/grades.dart'; +import 'package:mi_utem/widgets/calculadora_notas/display_notas_widget.dart'; +import 'package:mi_utem/widgets/calculadora_notas/editar_notas_widget.dart'; import 'package:mi_utem/widgets/custom_app_bar.dart'; -import 'package:mi_utem/widgets/nota_list_item.dart'; +import 'package:mi_utem/widgets/loading/loading_dialog.dart'; -class CalculadoraNotasScreen extends StatelessWidget { - CalculadoraNotasScreen({ - Key? key, - }) : super(key: key); +class CalculadoraNotasScreen extends StatefulWidget { + final Grades? grades; + + const CalculadoraNotasScreen({ + super.key, + this.grades, + }); @override - Widget build(BuildContext context) { - final controller = CalculatorController.to; + State createState() => _CalculadoraNotasScreenState(); +} - controller.makeEditable(); +class _CalculadoraNotasScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + showLoadingDialog(context); + final CalculatorController calculatorController = Get.find(); + calculatorController.makeEditable(); + calculatorController.updateWithGrades(widget.grades); + Navigator.pop(context); + }); + } + + @override + Widget build(BuildContext context) { return Scaffold( appBar: CustomAppBar( - title: Text("Calculadora de notas"), - ), - body: ListView( - padding: EdgeInsets.all(10), - children: [ - Card( - child: Stack( - alignment: Alignment.center, - children: [ - Container( - padding: EdgeInsets.all(20), - width: double.infinity, - child: RotationTransition( - turns: AlwaysStoppedAnimation(-20 / 360), - child: Text( - "Modo simulación".toUpperCase(), - style: TextStyle( - color: Colors.grey[200], - fontWeight: FontWeight.bold, - fontSize: 25, - ), - textAlign: TextAlign.center, - ), - ), - ), - Container( - padding: EdgeInsets.symmetric(vertical: 20, horizontal: 30), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - children: [ - Obx( - () => Text( - controller.calculatedFinalGrade - ?.toStringAsFixed(1) ?? - "--", - style: TextStyle( - fontSize: 40, - fontWeight: FontWeight.bold, - ), - ), - ), - /* Text( - _asignatura!.estadoCalculado, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), */ - ], - ), - Container(width: 10), - Container( - height: 80, - width: 0.5, - color: Colors.grey, - ), - Container(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - "Examen", - style: TextStyle(fontSize: 16), - ), - Container( - width: 80, - margin: EdgeInsets.only(left: 15), - child: Obx( - () => TextField( - controller: - controller.examGradeTextFieldController, - textAlign: TextAlign.center, - onChanged: (String value) { - controller.examGrade.value = - double.tryParse( - value.replaceAll(",", "."), - ); - }, - enabled: controller.canTakeExam, - decoration: InputDecoration( - hintText: controller - .minimumRequiredExamGrade - ?.toStringAsFixed(1) ?? - "", - filled: !controller.canTakeExam, - fillColor: Colors.grey.withOpacity(0.2), - disabledBorder: MainTheme - .theme.inputDecorationTheme.border! - .copyWith( - borderSide: BorderSide( - color: Colors.grey[300]!, - ), - ), - ), - keyboardType: - TextInputType.numberWithOptions( - decimal: true, - ), - ), - ), - ), - ], - ), - Container(height: 10), - Obx( - () => Row( - children: [ - Text( - "Pres.", - style: TextStyle(fontSize: 16), - ), - Container( - width: 80, - margin: EdgeInsets.only(left: 15), - child: TextField( - controller: TextEditingController( - text: controller - .calculatedPresentationGrade - ?.toStringAsFixed(1) ?? - "", - ), - textAlign: TextAlign.center, - enabled: false, - decoration: InputDecoration( - hintText: "Nota", - disabledBorder: MainTheme - .theme.inputDecorationTheme.border! - .copyWith( - borderSide: BorderSide( - color: Colors.transparent, - ), - ), - ), - keyboardType: - TextInputType.numberWithOptions( - decimal: true, - ), - ), - ), - ], - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - Card( - child: Stack( - alignment: Alignment.center, - children: [ - Container( - padding: EdgeInsets.all(20), - width: double.infinity, - child: RotationTransition( - turns: AlwaysStoppedAnimation(-20 / 360), - child: Text( - "Modo simulación".toUpperCase(), - style: TextStyle( - color: Colors.grey[200], - fontWeight: FontWeight.bold, - fontSize: 25, - ), - textAlign: TextAlign.center, - ), - ), - ), - Container( - padding: EdgeInsets.all(20), - child: Column( - children: [ - Obx( - () => ListView.separated( - shrinkWrap: true, - physics: ClampingScrollPhysics(), - separatorBuilder: (context, index) => - SizedBox(height: 10), - itemBuilder: (context, i) { - REvaluacion evaluacion = - controller.partialGrades[i]; - return NotaListItem( - evaluacion: IEvaluacion.fromRemote(evaluacion), - editable: true, - gradeController: - controller.gradeTextFieldControllers[i], - percentageController: - controller.percentageTextFieldControllers[i], - onChanged: (evaluacion) { - controller.changeGradeAt(i, evaluacion); - }, - onDelete: () => _deleteGrade(controller, i), - ); - }, - itemCount: controller.partialGrades.length, - ), - ), - SizedBox(height: 16), - TextButton( - onPressed: () => _addGrade(controller), - child: Text("Agregar nota"), - ), - ], - ), - ), - ], - ), + title: const Text("Calculadora de notas"), + actions: [ + IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: "Limpiar notas", + onPressed: () => Get.find().clearGrades(), ), ], ), - ); - } - - void _deleteGrade(CalculatorController controller, int index) { - AnalyticsService.logEvent("calculator_delete_grade"); - controller.removeGradeAt(index); - } - - void _addGrade(CalculatorController controller) { - AnalyticsService.logEvent("calculator_add_grade"); - controller.addGrade( - IEvaluacion( - nota: null, - porcentaje: null, - ), + body: SafeArea(child: ListView( + padding: const EdgeInsets.all(10), + children: [ + const DisplayNotasWidget(), + const EditarNotasWidget(), + ], + )), ); } } + diff --git a/lib/screens/carreras_screen.dart b/lib/screens/carreras_screen.dart index df5c85b..6d7afda 100644 --- a/lib/screens/carreras_screen.dart +++ b/lib/screens/carreras_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; - import 'package:mi_utem/widgets/custom_app_bar.dart'; final dynamic carreras = [ @@ -56,7 +55,7 @@ class _CarrerasScreenState extends State { subtitle: Text('${carreras[i]["codigo"]}/${carreras[i]["plan"]}'), /* leading: CircleAvatar( radius: 25, - backgroundColor: Get.theme.primaryColor, + backgroundColor: Theme.of(context).primaryColor, child: Icon( IconData(carreras[i]["icono"], fontFamily: 'MaterialIcons'), size: 25, diff --git a/lib/screens/credencial_screen.dart b/lib/screens/credencial_screen.dart index 998afc1..19180ce 100644 --- a/lib/screens/credencial_screen.dart +++ b/lib/screens/credencial_screen.dart @@ -1,161 +1,153 @@ import 'package:flutter/material.dart'; -import 'package:flutter_windowmanager/flutter_windowmanager.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; import 'package:mdi/mdi.dart'; -import 'package:mi_utem/controllers/carreras_controller.dart'; import 'package:mi_utem/models/carrera.dart'; -import 'package:mi_utem/models/usuario.dart'; +import 'package:mi_utem/models/pair.dart'; +import 'package:mi_utem/models/user/user.dart'; import 'package:mi_utem/services/analytics_service.dart'; -import 'package:mi_utem/services/perfil_service.dart'; +import 'package:mi_utem/services/auth_service.dart'; +import 'package:mi_utem/services/carreras_service.dart'; import 'package:mi_utem/services/review_service.dart'; -import 'package:mi_utem/widgets/credencial_card.dart'; +import 'package:mi_utem/widgets/credencial/credencial_card.dart'; import 'package:mi_utem/widgets/custom_app_bar.dart'; import 'package:mi_utem/widgets/custom_error_widget.dart'; import 'package:mi_utem/widgets/flip_widget.dart'; -import 'package:mi_utem/widgets/loading_indicator.dart'; +import 'package:mi_utem/widgets/loading/loading_indicator.dart'; +import 'package:screen_protector/screen_protector.dart'; class CredencialScreen extends StatefulWidget { - CredencialScreen({ - Key? key, - }) : super(key: key); + const CredencialScreen({ + super.key, + }); @override State createState() => _CredencialScreenState(); } class _CredencialScreenState extends State { - Future? _future; - Usuario? _usuario; - FlipController _flipController = FlipController(); + final FlipController _flipController = FlipController(); @override void initState() { ReviewService.addScreen("CredencialScreen"); - _secureScreen(); - _future = _getData(); + // Set device orientation to portrait + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + ScreenProtector.preventScreenshotOn(); + ScreenProtector.protectDataLeakageOn(); + ScreenProtector.protectDataLeakageWithBlur(); super.initState(); } - Future _secureScreen() async { - await FlutterWindowManager.addFlags(FlutterWindowManager.FLAG_SECURE); - } - - Future _desecureScreen() async { - await FlutterWindowManager.clearFlags(FlutterWindowManager.FLAG_SECURE); - } - @override void dispose() { - _desecureScreen(); + ScreenProtector.preventScreenshotOff(); + ScreenProtector.protectDataLeakageOff(); + SystemChrome.setPreferredOrientations(DeviceOrientation.values); super.dispose(); } - Future _getData() async { - try { - Usuario usuario = PerfilService.getLocalUsuario(); - setState(() { - _usuario = usuario; - }); - return usuario; - } catch (e) { - throw e; - } - } - @override - Widget build(BuildContext context) { - Carrera? carreraActiva = CarrerasController.to.selectedCarrera.value; + Widget build(BuildContext context) => Scaffold( + appBar: CustomAppBar( + title: const Text("Credencial universitaria"), + actions: [ + IconButton( + icon: Icon(_flipController.actualFace == FlipController.front ? Icons.info : Mdi.accountCircle), + onPressed: _flipController.flip?.call(), + ), + ], + ), + backgroundColor: Colors.grey[200], + body: FutureBuilder>( + future: () async { + final authService = Get.find(); + final carrerasService = Get.find(); - return Scaffold( - appBar: CustomAppBar( - title: Text("Credencial universitaria"), - actions: [ - IconButton( - icon: Icon(_flipController.actualFace == FlipController.front - ? Icons.info - : Mdi.accountCircle), - onPressed: () { - _flipController.flip!(); - }, - ), - ], - ), - backgroundColor: Colors.grey[200], - body: FutureBuilder( - future: _future, - builder: (context, snapshot) { - if (snapshot.hasError) { - return CustomErrorWidget( - title: "Ocurrió un error al generar tu crendencial", - error: snapshot.error); - } else { - if (snapshot.hasData) { - if (_usuario!.rut != null && - carreraActiva!.nombre != null && - carreraActiva.nombre!.isNotEmpty) { - return Center( - child: SafeArea( - child: CredencialCard( - usuario: _usuario, - carrera: carreraActiva, - controller: _flipController, - onFlip: (direction) => _onFlip(), - ), - ), - ); - } else { - return Container( - padding: EdgeInsets.all(20), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "😕", - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 50, - ), - ), - Container(height: 15), - Text( - "Ocurrió un error al generar tu credencial", - textAlign: TextAlign.center, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - Container(height: 15), - Text( - snapshot.error.toString(), - textAlign: TextAlign.center, - ), - ], - ), - ); - } - } else { - return Container( - padding: EdgeInsets.all(20), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Center( - child: LoadingIndicator(), - ), - ), - ], + final user = await authService.getUser(); + final carrera = await carrerasService.getCarreras(); + + return Pair(user, carrera); + }(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return CustomErrorWidget( + title: "Ocurrió un error al generar tu crendencial", + error: snapshot.error, + ); + } + + final pair = snapshot.data; + final user = pair?.a; + final carreraActiva = pair?.b; + + if (!snapshot.hasData) { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: LoadingIndicator.centered(), ), - ); - } - } - }, - ), - ); - } + ], + ), + ); + } - void _onFlip() { - AnalyticsService.logEvent("credencial_flip"); - setState(() {}); - } + if (user == null || user.rut == null || carreraActiva == null || carreraActiva.nombre == null) { + return CustomErrorWidget( + title: "Ocurrió un error al generar tu credencial. Por favor, intenta nuevamente.", + error: snapshot.error, + ); + } + + if (carreraActiva.nombre?.isNotEmpty == true) { + return Center( + child: SafeArea( + child: CredencialCard( + user: user, + carrera: carreraActiva, + controller: _flipController, + onFlip: (_) { + AnalyticsService.logEvent("credencial_flip"); + setState(() {}); + }, + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("😕", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 50, + ), + ), + const SizedBox(height: 15), + const Text("Ocurrió un error al generar tu credencial", + textAlign: TextAlign.center, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + const SizedBox(height: 15), + Text(snapshot.error.toString(), + textAlign: TextAlign.center, + ), + ], + ), + ); + }, + ), + ); } diff --git a/lib/screens/docentes_screen.dart b/lib/screens/docentes_screen.dart deleted file mode 100644 index 5875a03..0000000 --- a/lib/screens/docentes_screen.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/models/usuario.dart'; -import 'package:mi_utem/screens/usuario_screen.dart'; -import 'package:mi_utem/services/docentes_service.dart'; -import 'package:mi_utem/utils/debounce.dart'; -import 'package:mi_utem/widgets/custom_app_bar.dart'; -import 'package:mi_utem/widgets/custom_error_widget.dart'; -import 'package:mi_utem/widgets/loading_indicator.dart'; -import 'package:mi_utem/widgets/profile_photo.dart'; - -class DocentesScreen extends StatefulWidget { - DocentesScreen({Key? key}) : super(key: key); - - @override - _DocentesScreenState createState() => _DocentesScreenState(); -} - -class _DocentesScreenState extends State { - Future>? _futureDocentes; - late List _docentes; - late Debounce d; - - TextEditingController _controller = TextEditingController(); - - @override - void initState() { - super.initState(); - d = Debounce(Duration(seconds: 1), () { - _getDocentes(_controller.text); - }); - } - - Future> _getDocentes(String nombre) async { - setState(() { - _docentes = []; - _futureDocentes = null; - _futureDocentes = DocentesService.buscarDocentes(nombre); - }); - List docentes = await _futureDocentes!; - setState(() => _docentes = docentes); - return docentes; - } - - _search(String query) { - if (query.trim().length > 3) { - d.schedule(); - } else { - d.clear(); - setState(() { - _futureDocentes = null; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: CustomAppBar( - title: Text("Docentes"), - ), - body: SingleChildScrollView( - padding: EdgeInsets.symmetric(vertical: 20), - child: Column( - children: [ - Container( - padding: EdgeInsets.symmetric(horizontal: 15), - child: TextField( - decoration: InputDecoration( - hintText: "Escribe para buscar un docente", - prefixIcon: Icon(Icons.search), - ), - keyboardType: TextInputType.name, - onSubmitted: (query) { - _search(query); - }, - textCapitalization: TextCapitalization.words, - textInputAction: TextInputAction.search, - controller: _controller, - onChanged: (query) { - _search(query); - }, - ), - ), - Container(height: 20), - _futureDocentes != null - ? FutureBuilder>( - future: _futureDocentes, - builder: (context, snapshot) { - if (snapshot.hasError) { - return CustomErrorWidget( - title: "Ocurrió un error al obtener los docentes", - error: snapshot.error, - ); - } else { - if (snapshot.hasData && snapshot.data != null) { - if (snapshot.data!.length > 0) { - return ListView.separated( - physics: ScrollPhysics(), - shrinkWrap: true, - separatorBuilder: (context, index) => - Divider(height: 5, indent: 20, endIndent: 20), - itemBuilder: (BuildContext context, int i) { - Usuario docente = _docentes[i]; - return ListTile( - leading: ProfilePhoto( - usuario: docente, - radius: 20, - editable: false, - ), - title: Text( - docente.nombreCompleto!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text(docente.correoUtem ?? - docente.correoPersonal ?? - ""), - onTap: () async { - await Get.to( - () => UsuarioScreen( - tipo: 2, - query: {"nombre": docente.nombre}, - ), - routeName: Routes.perfil, - ); - }, - ); - }, - itemCount: _docentes.length, - ); - } else { - return CustomErrorWidget( - emoji: "🤔", - title: - "Parece que no se encontraron docentes que coincidan con tu búsqueda", - ); - } - } else { - return Container( - padding: EdgeInsets.all(20), - child: Center( - child: LoadingIndicator(), - ), - ); - } - } - }, - ) - : CustomErrorWidget( - emoji: "💅", - title: "Escribe para buscar un docente", - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/horario/horario_screen.dart b/lib/screens/horario/horario_screen.dart index 64a7a64..bf7bda6 100644 --- a/lib/screens/horario/horario_screen.dart +++ b/lib/screens/horario/horario_screen.dart @@ -1,126 +1,152 @@ import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:mi_utem/controllers/horario_controller.dart'; +import 'package:mi_utem/models/exceptions/custom_exception.dart'; import 'package:mi_utem/models/horario.dart'; import 'package:mi_utem/screens/horario/widgets/horario_main_scroller.dart'; import 'package:mi_utem/services/analytics_service.dart'; import 'package:mi_utem/services/review_service.dart'; import 'package:mi_utem/widgets/custom_app_bar.dart'; -import 'package:mi_utem/widgets/loading_indicator.dart'; +import 'package:mi_utem/widgets/custom_error_widget.dart'; +import 'package:mi_utem/widgets/loading/loading_dialog.dart'; +import 'package:mi_utem/widgets/loading/loading_indicator.dart'; import 'package:path_provider/path_provider.dart'; import 'package:screenshot/screenshot.dart'; import 'package:share_plus/share_plus.dart'; -class HorarioScreen extends StatelessWidget { - HorarioScreen({ - Key? key, - }) : super(key: key); +class HorarioScreen extends StatefulWidget { + const HorarioScreen({super.key}); + + @override + State createState() => _HorarioScreenState(); +} + +class _HorarioScreenState extends State { final ScreenshotController _screenshotController = ScreenshotController(); - final HorarioController controller = Get.put(HorarioController()); - - CustomAppBar get _appBar => CustomAppBar( - title: Text("Horario"), - actions: [ - Obx( - () => controller.horario.value != null && - !controller.isCenteredInCurrentPeriodAndDay.value - ? IconButton( - onPressed: () => _moveViewportToCurrentTime(), - icon: Icon(Icons.center_focus_strong), - ) - : Container(), + bool _forceRefresh = false; + final horarioController = Get.find(); + + @override + void initState() { + ReviewService.addScreen("HorarioScreen"); + _forceRefresh = false; + super.initState(); + } + + @override + Widget build(BuildContext context) { + horarioController.init(context); + return FutureBuilder( + future: () async { + _moveViewportToCurrentTime(); + final data = await horarioController.getHorario(forceRefresh: _forceRefresh); + _forceRefresh = false; + return data; + }(), + builder: (context, snapshot) { + if(snapshot.connectionState == ConnectionState.waiting) { + return Scaffold( + appBar: CustomAppBar( + title: Text("Horario"), + ), + body: LoadingIndicator.centeredDefault(), + ); + } + + final horario = snapshot.data; + final esErrorOffline = snapshot.hasError && snapshot.error is DioError && (snapshot.error as DioError).type == DioErrorType.cancel && (snapshot.error as DioError).response?.extra["offline"] == true; + if((snapshot.hasError && !esErrorOffline) || !snapshot.hasData || horario == null) { + String errorMessage = "Ocurrió un error al cargar el horario! Por favor intenta más tarde."; + final error = snapshot.error; + if(error != null) { + errorMessage = error is CustomException ? error.message : "Ocurrió un error al cargar el horario! Por favor intenta más tarde."; + } + + return Scaffold( + appBar: CustomAppBar( + title: Text("Horario"), + actions: [ + IconButton( + onPressed: _reloadData, + icon: Icon(Icons.refresh_sharp), + tooltip: "Forzar actualización del horario", + ) + ], + ), + body: Center( + child: CustomErrorWidget( + title: "Error al cargar el horario", + error: errorMessage, + ), + ), + ); + } + + return Scaffold( + appBar: CustomAppBar( + title: Text("Horario"), + actions: [ + IconButton( + onPressed: _reloadData, + icon: Icon(Icons.refresh_sharp), + tooltip: "Forzar actualización del horario", + ), + Obx(() => !horarioController.isCenteredInCurrentPeriodAndDay.value ? IconButton( + onPressed: _moveViewportToCurrentTime, + icon: Icon(Icons.center_focus_strong), + tooltip: "Centrar Horario En Hora Actual", + ) : Container()), + IconButton( + onPressed: () => _captureAndShareScreenshot(horario), + icon: Icon(Icons.share), + tooltip: "Compartir Horario", + ) + ], ), - Obx( - () => controller.horario.value != null - ? IconButton( - onPressed: () => - _captureAndShareScreenshot(controller.horario.value!), - icon: Icon(Icons.share), - ) - : Container(), - ) - ], - ); + body: SafeArea( + bottom: false, + child: Screenshot( + controller: _screenshotController, + child: HorarioMainScroller( + horario: horario, + ), + ), + ), + ); + }, + ); + } void _moveViewportToCurrentTime() { AnalyticsService.logEvent("horario_move_viewport_to_current_time"); - controller.moveViewportToCurrentPeriodAndDay(); + horarioController.moveViewportToCurrentPeriodAndDay(context); } void _captureAndShareScreenshot(Horario horario) async { + showLoadingDialog(context); AnalyticsService.logEvent("horario_capture_and_share_screenshot"); final horarioScroller = HorarioMainScroller( - controller: controller, horario: horario, showActive: false, ); final image = await _screenshotController.captureFromWidget( horarioScroller.basicHorario, - targetSize: - Size(HorarioMainScroller.totalWidth, HorarioMainScroller.totalHeight), + targetSize: Size(HorarioMainScroller.totalWidth, HorarioMainScroller.totalHeight), ); final directory = await getApplicationDocumentsDirectory(); final imagePath = await File('${directory.path}/horario.png').create(); await imagePath.writeAsBytes(image); + Navigator.pop(context); /// Share Plugin await Share.shareXFiles([XFile(imagePath.path)]); } - @override - Widget build(BuildContext context) { - ReviewService.addScreen("HorarioScreen"); - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeRight, - DeviceOrientation.landscapeLeft, - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); - - return Scaffold( - appBar: _appBar, - body: Obx( - () { - if ((controller.loadingHorario.value && - controller.horario.value == null) || - controller.horario.value == null) { - return Container( - padding: EdgeInsets.all(20), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Center( - child: LoadingIndicator(), - ), - ), - ], - ), - ); - } - - return Screenshot( - controller: _screenshotController, - child: HorarioMainScroller( - controller: controller, - horario: controller.horario.value!, - ), - ); - }, - ), - ); - } -} - -class HorarioBinding extends Bindings { - @override - void dependencies() { - Get.put(HorarioController(), permanent: true); - } + void _reloadData() => setState(() => _forceRefresh = true); } diff --git a/lib/screens/horario/widgets/horario_blocks_content.dart b/lib/screens/horario/widgets/horario_blocks_content.dart index ae194e1..2f9a652 100644 --- a/lib/screens/horario/widgets/horario_blocks_content.dart +++ b/lib/screens/horario/widgets/horario_blocks_content.dart @@ -1,9 +1,7 @@ -import 'dart:developer'; - import 'package:flutter/material.dart'; import 'package:mi_utem/models/horario.dart'; import 'package:mi_utem/themes/theme.dart'; -import 'package:mi_utem/widgets/bloque_ramo_card.dart'; +import 'package:mi_utem/widgets/horario/bloque_ramo_card.dart'; class HorarioBlocksContent extends StatelessWidget { final Horario horario; @@ -14,7 +12,7 @@ class HorarioBlocksContent extends StatelessWidget { final double borderWidth; const HorarioBlocksContent({ - Key? key, + super.key, required this.horario, required this.blockHeight, required this.blockWidth, @@ -25,25 +23,19 @@ class HorarioBlocksContent extends StatelessWidget { List get _children { final rows = []; - for (int blockIndex = 0; - blockIndex < horario.horarioEnlazado.length; - blockIndex++) { + for (int blockIndex = 0; blockIndex < horario.horarioEnlazado.length; blockIndex++) { final currentRow = []; if ((blockIndex % 2) == 0) { List bloquePorDias = horario.horarioEnlazado[blockIndex]; for (num dia = 0; dia < bloquePorDias.length; dia++) { BloqueHorario block = horario.horarioEnlazado[blockIndex][dia as int]; - log(block.asignatura?.nombre.toString() ?? "aaa"); - - currentRow.add( - ClassBlockCard( - block: block, - height: blockHeight, - width: blockWidth, - internalMargin: blockInternalMargin, - ), - ); + currentRow.add(ClassBlockCard( + block: block, + height: blockHeight, + width: blockWidth, + internalMargin: blockInternalMargin, + )); } rows.add(TableRow(children: currentRow)); } @@ -52,22 +44,21 @@ class HorarioBlocksContent extends StatelessWidget { } @override - Widget build(BuildContext context) { - return Table( - defaultColumnWidth: FixedColumnWidth(blockWidth), - border: TableBorder( - horizontalInside: BorderSide( - color: borderColor, - style: BorderStyle.solid, - width: borderWidth, - ), - verticalInside: BorderSide( - color: borderColor, - style: BorderStyle.solid, - width: borderWidth, - ), + Widget build(BuildContext context) => Table( + defaultColumnWidth: FixedColumnWidth(blockWidth), + border: TableBorder( + horizontalInside: BorderSide( + color: borderColor, + style: BorderStyle.solid, + width: borderWidth, ), - children: _children, - ); - } + verticalInside: BorderSide( + color: borderColor, + style: BorderStyle.solid, + width: borderWidth, + ), + ), + children: _children, + ); + } diff --git a/lib/screens/horario/widgets/horario_corner.dart b/lib/screens/horario/widgets/horario_corner.dart index 22823c4..c203f2e 100644 --- a/lib/screens/horario/widgets/horario_corner.dart +++ b/lib/screens/horario/widgets/horario_corner.dart @@ -13,30 +13,26 @@ class HorarioCorner extends StatelessWidget { this.backgroundColor = MainTheme.lightGrey, }); - List get _children => [ - TableRow( - children: [ - Container( - height: height, - width: width, - color: backgroundColor, - ), - ], - ), - ]; - @override - Widget build(BuildContext context) { - return Table( - defaultColumnWidth: FixedColumnWidth(width), - border: TableBorder( - right: BorderSide( - color: Color(0xFFBDBDBD), - style: BorderStyle.solid, - width: 2, - ), + Widget build(BuildContext context) => Table( + defaultColumnWidth: FixedColumnWidth(width), + border: TableBorder( + right: BorderSide( + color: Color(0xFFBDBDBD), + style: BorderStyle.solid, + width: 2, + ), + ), + children: [ + TableRow( + children: [ + Container( + height: height, + width: width, + color: backgroundColor, + ), + ], ), - children: _children, - ); - } + ], + ); } diff --git a/lib/screens/horario/widgets/horario_days_header.dart b/lib/screens/horario/widgets/horario_days_header.dart index f9ce555..5dfa255 100644 --- a/lib/screens/horario/widgets/horario_days_header.dart +++ b/lib/screens/horario/widgets/horario_days_header.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:mi_utem/controllers/horario_controller.dart'; import 'package:mi_utem/models/horario.dart'; import 'package:mi_utem/themes/theme.dart'; -import 'package:mi_utem/widgets/bloque_dias_card.dart'; +import 'package:mi_utem/widgets/horario/bloque_dias_card.dart'; class HorarioDaysHeader extends StatelessWidget { - final HorarioController? controller; final Horario horario; final double height; final double dayWidth; @@ -16,7 +16,6 @@ class HorarioDaysHeader extends StatelessWidget { const HorarioDaysHeader({ Key? key, - this.controller, required this.horario, required this.height, required this.dayWidth, @@ -26,44 +25,33 @@ class HorarioDaysHeader extends StatelessWidget { this.borderWidth = 2, }); - List get _children { - return [ - TableRow( - children: horario.diasHorario - .asMap() - .entries - .map( - (entry) => BloqueDiasCard( - day: entry.value!, - height: height, - width: dayWidth, - active: showActiveDay && - entry.key == controller?.indexOfCurrentDayStartingAtMonday, - backgroundColor: backgroundColor, - ), - ) - .toList(), - ), - ]; - } + List get _children => [ + TableRow( + children: horario.diasHorario.asMap().entries.map((entry) => BloqueDiasCard( + day: entry.value, + height: height, + width: dayWidth, + active: showActiveDay && entry.key == Get.find().indexOfCurrentDayStartingAtMonday, + backgroundColor: backgroundColor, + )).toList(), + ), + ]; @override - Widget build(BuildContext context) { - return Table( - defaultColumnWidth: FixedColumnWidth(dayWidth), - border: TableBorder( - verticalInside: BorderSide( - color: borderColor, - style: BorderStyle.solid, - width: borderWidth, - ), - bottom: BorderSide( - color: borderColor, - style: BorderStyle.solid, - width: borderWidth, - ), + Widget build(BuildContext context) => Table( + defaultColumnWidth: FixedColumnWidth(dayWidth), + border: TableBorder( + verticalInside: BorderSide( + color: borderColor, + style: BorderStyle.solid, + width: borderWidth, + ), + bottom: BorderSide( + color: borderColor, + style: BorderStyle.solid, + width: borderWidth, ), - children: _children, - ); - } + ), + children: _children, + ); } diff --git a/lib/screens/horario/widgets/horario_indicator.dart b/lib/screens/horario/widgets/horario_indicator.dart index 42f4dab..a5ec740 100644 --- a/lib/screens/horario/widgets/horario_indicator.dart +++ b/lib/screens/horario/widgets/horario_indicator.dart @@ -4,26 +4,25 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:mi_utem/controllers/horario_controller.dart'; import 'package:mi_utem/services/analytics_service.dart'; +import 'package:mi_utem/widgets/horario/ticker_time_text.dart'; class HorarioIndicator extends StatefulWidget { static const _height = 2.0; static const _circleRadius = 10.0; static const _tapAreaRadius = 15.0; - final HorarioController controller; final EdgeInsets initialMargin; final double heightByMinute; final double maxWidth; final Color color; const HorarioIndicator({ - Key? key, - required this.controller, + super.key, required this.initialMargin, required this.heightByMinute, required this.maxWidth, this.color = Colors.red, - }) : super(key: key); + }); @override State createState() => _HorarioIndicatorState(); @@ -34,10 +33,7 @@ class _HorarioIndicatorState extends State { @override void initState() { - _timer = Timer.periodic( - Duration(seconds: 60), - (Timer t) => setState(() {}), - ); + _timer = Timer.periodic(Duration(seconds: 30), (Timer t) => setState(() {})); super.initState(); } @@ -47,146 +43,63 @@ class _HorarioIndicatorState extends State { super.dispose(); } - double get _centerLineYPosition => - (widget.controller.minutesFromStart * widget.heightByMinute); + HorarioController _horarioController = Get.find(); + + double get _centerLineYPosition => (_horarioController.minutesFromStart * widget.heightByMinute); double get _startLineXPosition => widget.initialMargin.left; @override Widget build(BuildContext context) { - final circleTapAreaTop = _centerLineYPosition - - HorarioIndicator._circleRadius - - HorarioIndicator._tapAreaRadius; - final circleTapAreaLeft = _startLineXPosition - - HorarioIndicator._circleRadius - - HorarioIndicator._tapAreaRadius; + final circleTapAreaTop = _centerLineYPosition - HorarioIndicator._circleRadius - HorarioIndicator._tapAreaRadius; + final circleTapAreaLeft = _startLineXPosition - HorarioIndicator._circleRadius - HorarioIndicator._tapAreaRadius; final lineTop = _centerLineYPosition - HorarioIndicator._height / 2; final lineLeft = _startLineXPosition; return Stack( children: [ - if (lineTop > 0 && lineLeft > 0) - Container( - margin: EdgeInsets.only( - top: lineTop, - left: lineLeft, - ), - height: HorarioIndicator._height, - width: widget.maxWidth, - color: widget.color, + if (lineTop > 0 && lineLeft > 0) Container( + margin: EdgeInsets.only( + top: lineTop, + left: lineLeft, ), - if (circleTapAreaTop > 0 && circleTapAreaLeft > 0) - Container( - margin: EdgeInsets.only( - top: circleTapAreaTop, - left: circleTapAreaLeft, - ), - child: GestureDetector( - onTap: () => _onIndicatorDotTap(), - child: Container( - padding: EdgeInsets.all(HorarioIndicator._tapAreaRadius), + height: HorarioIndicator._height, + width: widget.maxWidth, + color: widget.color, + ), + if (circleTapAreaTop > 0 && circleTapAreaLeft > 0) Container( + margin: EdgeInsets.only( + top: circleTapAreaTop, + left: circleTapAreaLeft, + ), + child: GestureDetector( + onTap: () { + AnalyticsService.logEvent('horario_indicator_dot_tap'); + _horarioController.setIndicatorIsOpen(!_horarioController.indicatorIsOpen.value); + }, + child: Container( + padding: EdgeInsets.all(HorarioIndicator._tapAreaRadius), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(HorarioIndicator._tapAreaRadius * 2), + ), + child: Obx(() => AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: HorarioIndicator._circleRadius * 2, + width: _horarioController.indicatorIsOpen.value ? 50 : HorarioIndicator._circleRadius * 2, decoration: BoxDecoration( - borderRadius: BorderRadius.circular( - HorarioIndicator._tapAreaRadius * 2, - ), + color: widget.color, + borderRadius: + BorderRadius.circular(HorarioIndicator._circleRadius), ), - child: Obx( - () => AnimatedContainer( - duration: const Duration(milliseconds: 300), - height: HorarioIndicator._circleRadius * 2, - width: widget.controller.indicatorIsOpen.value - ? 50 - : HorarioIndicator._circleRadius * 2, - decoration: BoxDecoration( - color: widget.color, - borderRadius: - BorderRadius.circular(HorarioIndicator._circleRadius), - ), - child: widget.controller.indicatorIsOpen.value - ? Center( - child: _TickerTimeText(time: DateTime.now()), - ) - : Container(), - ), - ), - ), + child: _horarioController.indicatorIsOpen.value ? Center( + child: TickerTimeText(time: DateTime.now()), + ) : Container(), + )), ), ), + ), ], ); } - - _onIndicatorDotTap() { - AnalyticsService.logEvent('horario_indicator_dot_tap'); - widget.controller.indicatorIsOpen.value = - !widget.controller.indicatorIsOpen.value; - } -} - -class _TickerTimeText extends StatefulWidget { - final DateTime time; - - const _TickerTimeText({ - Key? key, - required this.time, - }) : super(key: key); - - @override - State<_TickerTimeText> createState() => __TickerTimeTextState(); -} - -class __TickerTimeTextState extends State<_TickerTimeText> { - Timer? _timer; - bool _showColon = true; - - @override - void initState() { - _timer = Timer.periodic( - Duration(seconds: 1), - (Timer t) => setState(() { - _showColon = !_showColon; - }), - ); - super.initState(); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - String get _timeHour => "${widget.time.hour}"; - String get _timeMinutes => "${widget.time.minute}"; - - @override - Widget build(BuildContext context) { - return RichText( - overflow: TextOverflow.fade, - maxLines: 1, - text: TextSpan( - children: [ - TextSpan( - text: _timeHour, - style: Get.textTheme.bodySmall?.copyWith( - color: Colors.white, - ), - ), - TextSpan( - text: ":", - style: Get.textTheme.bodySmall?.copyWith( - color: _showColon ? Colors.white : Colors.transparent, - ), - ), - TextSpan( - text: _timeMinutes, - style: Get.textTheme.bodySmall?.copyWith( - color: Colors.white, - ), - ), - ], - ), - ); - } } diff --git a/lib/screens/horario/widgets/horario_main_scroller.dart b/lib/screens/horario/widgets/horario_main_scroller.dart index bee02e2..25acd43 100644 --- a/lib/screens/horario/widgets/horario_main_scroller.dart +++ b/lib/screens/horario/widgets/horario_main_scroller.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:get/get_state_manager/get_state_manager.dart'; +import 'package:get/get.dart'; import 'package:mi_utem/controllers/horario_controller.dart'; import 'package:mi_utem/models/horario.dart'; import 'package:mi_utem/screens/horario/widgets/horario_blocks_content.dart'; @@ -23,193 +23,157 @@ class HorarioMainScroller extends StatefulWidget { final Horario horario; final bool showActive; - final HorarioController controller; const HorarioMainScroller({ - Key? key, + super.key, required this.horario, - required this.controller, this.showActive = true, - }) : super(key: key); + }); @override _HorarioMainScrollerState createState() => _HorarioMainScrollerState(); - static double get daysWidth => dayWidth * HorarioController.daysCount; - static double get periodsHeight => - periodHeight * HorarioController.periodsCount; + static double get daysWidth => dayWidth * Get.find().daysCount; + static double get periodsHeight => periodHeight * Get.find().periodsCount; static double get totalWidth => daysWidth + periodWidth; static double get totalHeight => periodsHeight + dayHeight; - Widget get _horarioBlocksContent => HorarioBlocksContent( - horario: horario, - blockHeight: blockHeight, - blockWidth: blockWidth, - blockInternalMargin: blockInternalMargin, - ); - - Widget get _horarioDaysHeader => HorarioDaysHeader( - controller: controller, - horario: horario, - height: dayHeight, - dayWidth: dayWidth, - showActiveDay: showActive, - ); - - Widget get _horarioPeriodsHeader => HorarioPeriodsHeader( - controller: controller, - horario: horario, - width: periodWidth, - periodHeight: periodHeight, - showActivePeriod: showActive, - ); - - Widget get _horarioCorner => HorarioCorner( - height: dayHeight, - width: periodWidth, - ); - + Widget get _horarioBlocksContent => HorarioBlocksContent(horario: horario, blockHeight: blockHeight, blockWidth: blockWidth, blockInternalMargin: blockInternalMargin); + Widget get _horarioDaysHeader => HorarioDaysHeader(horario: horario, height: dayHeight, dayWidth: dayWidth, showActiveDay: showActive); + Widget get _horarioPeriodsHeader => HorarioPeriodsHeader(horario: horario, width: periodWidth, periodHeight: periodHeight, showActivePeriod: showActive); + Widget get _horarioCorner => HorarioCorner(height: dayHeight, width: periodWidth); Widget get basicHorario => Container( - color: Colors.white, - child: Column( - children: [ - Row( - children: [ - _horarioCorner, - _horarioDaysHeader, - ], - ), - Row( - children: [ - _horarioPeriodsHeader, - _horarioBlocksContent, - ], - ) - ], - ), - ); + color: Colors.white, + child: Column( + children: [ + Row(children: [ + _horarioCorner, + _horarioDaysHeader, + ]), + Row(children: [ + _horarioPeriodsHeader, + _horarioBlocksContent, + ]) + ], + ), + ); } class _HorarioMainScrollerState extends State { + + final controller = Get.find(); + @override void initState() { + controller.setOnUpdate(() => setState(() {})); super.initState(); } @override void dispose() { + controller.setOnUpdate(null); super.dispose(); } @override - Widget build(BuildContext context) { - return Container( - color: Colors.white, - child: GetBuilder(builder: (controller) { - return Stack( - children: [ - Container( - height: HorarioMainScroller.periodsHeight, - width: HorarioMainScroller.daysWidth, - margin: EdgeInsets.only( - top: HorarioMainScroller.dayHeight * - widget.controller.zoom.value, - left: HorarioMainScroller.periodWidth * - widget.controller.zoom.value, - ), - child: InteractiveViewer( - transformationController: - widget.controller.blockContentController, - maxScale: HorarioMainScroller.defaultMaxScale, - minScale: HorarioMainScroller.defaultMinScale, - panAxis: PanAxis.free, - clipBehavior: Clip.none, - constrained: false, - onInteractionUpdate: (interaction) {}, - child: SafeArea( - child: widget._horarioBlocksContent, - ), + Widget build(BuildContext context) => Obx(() => Container( + color: Colors.white, + child: Stack( + children: [ + /* Bloques del Horario */ + Container( + height: HorarioMainScroller.periodsHeight, + width: HorarioMainScroller.daysWidth, + margin: EdgeInsets.only( + top: HorarioMainScroller.dayHeight * controller.zoom.value, + left: HorarioMainScroller.periodWidth * controller.zoom.value, + ), + child: ClipRect( + child: InteractiveViewer( + transformationController: controller.blockContentController, + maxScale: HorarioMainScroller.defaultMaxScale, + minScale: HorarioMainScroller.defaultMinScale, + panAxis: PanAxis.free, + clipBehavior: Clip.none, + constrained: false, + onInteractionUpdate: (interaction) {}, + child: SafeArea( + child: widget._horarioBlocksContent, ), ), - Container( - width: HorarioMainScroller.daysWidth, - height: HorarioMainScroller.dayHeight, - margin: EdgeInsets.only( - left: HorarioMainScroller.periodWidth * - widget.controller.zoom.value, - ), - child: InteractiveViewer( - transformationController: - widget.controller.daysHeaderController, - maxScale: HorarioMainScroller.defaultMaxScale, - minScale: HorarioMainScroller.defaultMinScale, - panAxis: PanAxis.free, - scaleEnabled: false, - clipBehavior: Clip.none, - constrained: false, - onInteractionUpdate: (interaction) {}, - child: SafeArea( - child: widget._horarioDaysHeader, - ), - ), + ), + ), + /* Lista de Días */ + Container( + width: HorarioMainScroller.daysWidth, + height: HorarioMainScroller.dayHeight, + margin: EdgeInsets.only(left: HorarioMainScroller.periodWidth * controller.zoom.value), + child: ClipRect(child: InteractiveViewer( + transformationController: controller.daysHeaderController, + maxScale: HorarioMainScroller.defaultMaxScale, + minScale: HorarioMainScroller.defaultMinScale, + panAxis: PanAxis.free, + scaleEnabled: false, + clipBehavior: Clip.none, + constrained: false, + onInteractionUpdate: (interaction) {}, + child: SafeArea( + child: widget._horarioDaysHeader, ), - Container( - width: HorarioMainScroller.periodWidth, - height: HorarioMainScroller.dayHeight, - child: InteractiveViewer( - transformationController: widget.controller.cornerController, - maxScale: HorarioMainScroller.defaultMaxScale, - minScale: HorarioMainScroller.defaultMinScale, - panAxis: PanAxis.free, - scaleEnabled: false, - panEnabled: false, - clipBehavior: Clip.none, - constrained: false, - onInteractionUpdate: (interaction) {}, - child: SafeArea( - child: widget._horarioCorner, - ), - ), + )), + ), + /* Esquina del Horario */ + Container( + width: HorarioMainScroller.periodWidth, + height: HorarioMainScroller.dayHeight, + child: ClipRect(child: InteractiveViewer( + transformationController: controller.cornerController, + maxScale: HorarioMainScroller.defaultMaxScale, + minScale: HorarioMainScroller.defaultMinScale, + panAxis: PanAxis.free, + scaleEnabled: false, + panEnabled: false, + clipBehavior: Clip.none, + constrained: false, + onInteractionUpdate: (interaction) {}, + child: SafeArea( + child: widget._horarioCorner, ), - Container( - width: HorarioMainScroller.periodWidth, - height: HorarioMainScroller.periodsHeight, - margin: EdgeInsets.only( - top: HorarioMainScroller.dayHeight * - widget.controller.zoom.value, - ), - child: InteractiveViewer( - transformationController: - widget.controller.periodHeaderController, - maxScale: HorarioMainScroller.defaultMaxScale, - minScale: HorarioMainScroller.defaultMinScale, - panAxis: PanAxis.free, - scaleEnabled: false, - clipBehavior: Clip.none, - constrained: false, - onInteractionUpdate: (interaction) {}, - child: SafeArea( - child: Stack( - children: [ - widget._horarioPeriodsHeader, - HorarioIndicator( - controller: widget.controller, - maxWidth: HorarioMainScroller.daysWidth, - initialMargin: EdgeInsets.only( - top: HorarioMainScroller.dayHeight, - left: HorarioMainScroller.periodWidth, - ), - heightByMinute: HorarioMainScroller.blockHeight / 100, - ), - ], + )), + ), + /* Lista de Horas */ + Container( + width: HorarioMainScroller.periodWidth, + height: HorarioMainScroller.periodsHeight, + margin: EdgeInsets.only(top: HorarioMainScroller.dayHeight * controller.zoom.value), + child: InteractiveViewer( + transformationController: controller.periodHeaderController, + maxScale: HorarioMainScroller.defaultMaxScale, + minScale: HorarioMainScroller.defaultMinScale, + panAxis: PanAxis.free, + scaleEnabled: false, + clipBehavior: Clip.none, + constrained: false, + onInteractionUpdate: (interaction) {}, + child: SafeArea( + child: Stack( + children: [ + widget._horarioPeriodsHeader, + HorarioIndicator( + maxWidth: HorarioMainScroller.daysWidth, + initialMargin: EdgeInsets.only( + top: HorarioMainScroller.dayHeight, + left: HorarioMainScroller.periodWidth, + ), + heightByMinute: HorarioMainScroller.blockHeight / 100, ), - ), + ], ), ), - ], - ); - }), - ); - } + ), + ), + ], + ), + )); } diff --git a/lib/screens/horario/widgets/horario_periods_header.dart b/lib/screens/horario/widgets/horario_periods_header.dart index 772ac0c..f9050c7 100644 --- a/lib/screens/horario/widgets/horario_periods_header.dart +++ b/lib/screens/horario/widgets/horario_periods_header.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import 'package:mi_utem/controllers/horario_controller.dart'; import 'package:mi_utem/models/horario.dart'; import 'package:mi_utem/themes/theme.dart'; -import 'package:mi_utem/widgets/bloque_periodo_card.dart'; +import 'package:mi_utem/widgets/horario/bloque_periodo_card.dart'; class HorarioPeriodsHeader extends StatelessWidget { - final HorarioController? controller; final Horario horario; final double periodHeight; final double width; @@ -15,8 +15,7 @@ class HorarioPeriodsHeader extends StatelessWidget { final bool showActivePeriod; const HorarioPeriodsHeader({ - Key? key, - this.controller, + super.key, required this.horario, required this.periodHeight, required this.width, @@ -26,46 +25,33 @@ class HorarioPeriodsHeader extends StatelessWidget { this.borderWidth = 2, }); - List get _children { - return horario.horasInicio - .asMap() - .entries - .map( - (e) => TableRow( - children: [ - BloquePeriodoCard( - inicio: horario.horasInicio[e.key], - intermedio: horario.horasIntermedio[e.key], - fin: horario.horasTermino[e.key], - active: showActivePeriod && - controller?.indexOfCurrentPeriod == e.key, - height: periodHeight, - width: width, - backgroundColor: backgroundColor, - ), - ], - ), - ) - .toList(); - } - @override - Widget build(BuildContext context) { - return Table( - defaultColumnWidth: FixedColumnWidth(width), - border: TableBorder( - horizontalInside: BorderSide( - color: borderColor, - style: BorderStyle.solid, - width: borderWidth, - ), - right: BorderSide( - color: borderColor, - style: BorderStyle.solid, - width: borderWidth, - ), + Widget build(BuildContext context) => Table( + defaultColumnWidth: FixedColumnWidth(width), + border: TableBorder( + horizontalInside: BorderSide( + color: borderColor, + style: BorderStyle.solid, + width: borderWidth, ), - children: _children, - ); - } + right: BorderSide( + color: borderColor, + style: BorderStyle.solid, + width: borderWidth, + ), + ), + children: horario.horasInicio.asMap().entries.map((e) => TableRow( + children: [ + BloquePeriodoCard( + inicio: horario.horasInicio[e.key], + intermedio: horario.horasIntermedio[e.key], + fin: horario.horasTermino[e.key], + active: showActivePeriod && Get.find().indexOfCurrentPeriod == e.key, + height: periodHeight, + width: width, + backgroundColor: backgroundColor, + ), + ], + )).toList(), + ); } diff --git a/lib/screens/login_screen/login_screen.dart b/lib/screens/login_screen/login_screen.dart index ca11827..199e8d8 100644 --- a/lib/screens/login_screen/login_screen.dart +++ b/lib/screens/login_screen/login_screen.dart @@ -1,306 +1,21 @@ -import 'dart:convert'; -import 'dart:math'; - -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/helpers/snackbars.dart'; -import 'package:mi_utem/models/usuario.dart'; -import 'package:mi_utem/services/analytics_service.dart'; -import 'package:mi_utem/services/auth_service.dart'; -import 'package:mi_utem/services/remote_config/remote_config.dart'; -import 'package:mi_utem/widgets/acerca_dialog.dart'; -import 'package:mi_utem/widgets/dialogs/monkey_error_dialog.dart'; -import 'package:mi_utem/widgets/dialogs/not_ready_dialog.dart'; -import 'package:mi_utem/widgets/loading_dialog.dart'; -import 'package:mi_utem/widgets/login_text_form_field.dart'; -import 'package:video_player/video_player.dart'; - -part '_background.dart'; - -class LoginScreen extends StatefulWidget { - LoginScreen({Key? key}) : super(key: key); - - @override - _LoginScreenState createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - TextEditingController _correoController = TextEditingController(); - TextEditingController _contraseniaController = TextEditingController(); - - GlobalKey _formKey = GlobalKey(); - - @override - void initState() { - super.initState(); - - SystemChrome.setSystemUIOverlayStyle( - SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarBrightness: Brightness.light, - statusBarIconBrightness: Brightness.light, - systemNavigationBarColor: Colors.black, - systemNavigationBarIconBrightness: Brightness.light, - ), - ); - - _correoController.text = ""; - _contraseniaController.text = ""; - - SchedulerBinding.instance.addPostFrameCallback((_) { - _checkAndPerformUpdate(); - }); - } +import 'package:mi_utem/widgets/login_screen/background.dart'; +import 'package:mi_utem/widgets/login_screen/login_form.dart'; - Future _checkAndPerformUpdate() async { - /* try { - VersionStatus status = - await NewVersion(context: context).getVersionStatus(); - print("status.localVersion ${status.localVersion}"); - print("status.storeVersion ${status.storeVersion}"); - - var localVersion = status.localVersion.split("."); - var storeVersion = status.storeVersion.split("."); - if (storeVersion[0].compareTo(localVersion[0]) > 0) { - if (Platform.isAndroid) { - AppUpdateInfo info = await InAppUpdate.checkForUpdate(); - - if (info.updateAvailable == true) { - await InAppUpdate.performImmediateUpdate(); - } - } - } else if (storeVersion[1].compareTo(localVersion[1]) > 0) { - if (Platform.isAndroid) { - AppUpdateInfo info = await InAppUpdate.checkForUpdate(); - - if (info.updateAvailable == true) { - await InAppUpdate.startFlexibleUpdate(); - await InAppUpdate.completeFlexibleUpdate(); - } - } - } else if (storeVersion[2].compareTo(localVersion[2]) > 0) { - print("MINOR"); - } - - return; - } catch (error) { - print("_checkAndPerformUpdate Error: ${error.toString()}"); - } */ - } - - String get _creditText { - List texts = jsonDecode(RemoteConfigService.creditos); - - Random random = new Random(); - - return texts[random.nextInt(texts.length)]; - } +class LoginScreen extends StatelessWidget { + const LoginScreen({super.key}); @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: KeyboardVisibilityBuilder( - builder: (context, isKeyboardVisible) { - return _Background( - child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return SingleChildScrollView( - padding: EdgeInsets.symmetric(horizontal: 20), - child: ConstrainedBox( - constraints: constraints.copyWith( - minHeight: constraints.maxHeight, - maxHeight: double.infinity, - ), - child: IntrinsicHeight( - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container(height: constraints.maxHeight * 0.1), - Expanded( - child: SafeArea( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Hero( - tag: 'utemLogo', - child: Image.asset( - 'assets/images/utem_logo_color_blanco.png', - width: 250, - ), - ), - ], - ), - ), - ), - Container(height: constraints.maxHeight * 0.1), - AutofillGroup( - child: Column( - children: [ - LoginTextFormField( - controller: _correoController, - hintText: 'nombre@utem.cl', - labelText: 'Correo UTEM', - textCapitalization: TextCapitalization.none, - keyboardType: TextInputType.emailAddress, - inputFormatters: [ - FilteringTextInputFormatter.deny( - RegExp(" ")), - ], - icon: Icons.person, - autofillHints: [AutofillHints.username], - validator: (String value) { - if (value.isEmpty) { - return 'Debe ingresar un correo UTEM'; - } else if (value.contains("@") && - !value.endsWith("@utem.cl")) { - return 'Debe ingresar un correo UTEM'; - } - }, - ), - LoginTextFormField( - controller: _contraseniaController, - hintText: '• • • • • • • • •', - labelText: 'Contraseña', - textCapitalization: TextCapitalization.none, - icon: Icons.lock, - obscureText: true, - autofillHints: [AutofillHints.password], - validator: (String value) { - if (value.isEmpty) { - return 'Debe ingresar una contraseña'; - } - }, - ) - ], - ), - ), - TextButton( - onPressed: () => _login(), - child: Text("Iniciar"), - ), - Container(height: constraints.maxHeight * 0.1), - if (!isKeyboardVisible) - Expanded( - child: SafeArea( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Padding( - padding: EdgeInsets.all(10), - child: SafeArea( - child: GestureDetector( - child: MarkdownBody( - selectable: false, - styleSheet: MarkdownStyleSheet( - textAlign: WrapAlignment.center, - p: TextStyle( - color: Colors.white, - ), - ), - data: _creditText, - ), - onTap: () { - Get.toNamed( - Routes.about, - ); - }, - ), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - ); - }, - ), - ); - }, + Widget build(BuildContext context) => Scaffold( + resizeToAvoidBottomInset: false, + backgroundColor: Colors.black, + body: LoginBackground( + child: LayoutBuilder( + builder: (context, BoxConstraints constraints) => LoginForm( + constraints: constraints, + ), ), - ); - } - - Future _login() async { - final correo = _correoController.text; - final contrasenia = _contraseniaController.text; - - if (correo == "error@utem.cl") { - Get.dialog(MonkeyErrorDialog()); - } else if (correo == "test@utem.cl" && contrasenia == "test") { - showDefaultSnackbar( - "Error", - "Usuario o contraseña incorrecta", - ); - } else { - if (_formKey.currentState?.validate() ?? false) { - Get.dialog( - LoadingDialog(), - barrierDismissible: false, - ); - - try { - bool esPrimeraVez = await AuthService.esPrimeraVez(); - Usuario usuario = await AuthService.login(correo, contrasenia, true); - - AnalyticsService.logEvent('login'); - AnalyticsService.setUser(usuario); - - Get.toNamed( - Routes.home, - ); - - if (esPrimeraVez) { - Get.dialog( - AcercaDialog(), - ); - } - } on DioError catch (e) { - print(e.message); - Get.back(); - if (e.response?.statusCode == 403) { - if (e.response?.data["codigoInterno"]?.toString() == "4") { - Get.dialog(NotReadyDialog()); - } else { - showDefaultSnackbar( - "Error", - "Usuario o contraseña incorrecta", - ); - } - } else if (e.response?.statusCode != null && - e.response!.statusCode.toString().startsWith("5")) { - print(e.response?.data); - Get.dialog(MonkeyErrorDialog()); - } else { - print(e.response?.data); - showDefaultSnackbar( - "Error", - "Ocurrió un error inesperado 😢", - ); - } - } catch (e) { - print(e.toString()); - Get.back(); - showDefaultSnackbar( - "Error", - "Ocurrió un error inesperado 😢", - ); - } - } - } - } + ), + ); } + diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index bd0759d..495b41e 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -6,31 +6,42 @@ import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_markdown/flutter_markdown.dart"; import "package:get/get.dart"; -import 'package:mi_utem/controllers/grades_changes_controller.dart'; -import "package:mi_utem/models/usuario.dart"; -import "package:mi_utem/services/perfil_service.dart"; +import "package:mi_utem/models/novedades/ibanner.dart"; +import "package:mi_utem/models/preferencia.dart"; +import "package:mi_utem/models/user/user.dart"; +import "package:mi_utem/repositories/noticias_repository.dart"; +import "package:mi_utem/repositories/permiso_ingreso_repository.dart"; +import "package:mi_utem/services/auth_service.dart"; +import "package:mi_utem/services/grades_service.dart"; import "package:mi_utem/services/remote_config/remote_config.dart"; import "package:mi_utem/services/review_service.dart"; -import 'package:mi_utem/widgets/banners_section.dart'; import "package:mi_utem/widgets/custom_app_bar.dart"; import "package:mi_utem/widgets/custom_drawer.dart"; -import "package:mi_utem/widgets/noticias_carrusel.dart"; -import "package:mi_utem/widgets/permisos_section.dart"; -import "package:mi_utem/widgets/quick_menu_section.dart"; +import "package:mi_utem/widgets/main_screen/novedades/banners_section.dart"; +import "package:mi_utem/widgets/main_screen/permisos/permisos_section.dart"; +import "package:mi_utem/widgets/noticias/noticias_carrusel_widget.dart"; +import "package:mi_utem/widgets/pull_to_refresh.dart"; +import 'package:mi_utem/widgets/quick_access/quick_menu_section.dart'; class MainScreen extends StatefulWidget { - final Usuario usuario; - MainScreen({Key? key, required this.usuario}) : super(key: key); + const MainScreen({ + super.key, + }); @override _MainScreenState createState() => _MainScreenState(); } class _MainScreenState extends State { + + List _banners = const []; + User? _user; + final _authService = Get.find(); + @override void initState() { super.initState(); - PerfilService.saveFcmToken(); + _authService.saveFCMToken(); SystemChrome.setSystemUIOverlayStyle( SystemUiOverlayStyle( statusBarColor: Colors.transparent, @@ -42,73 +53,77 @@ class _MainScreenState extends State { ); ReviewService.addScreen("MainScreen"); - ReviewService.checkAndRequestReview(); + ReviewService.checkAndRequestReview(context); + + loadData(forceRefresh: false); + + _authService.getUser().then((user) => setState(() => _user = user)); } - @override - void dispose() { - super.dispose(); + Future loadData({ bool forceRefresh = true }) async { + if(forceRefresh) { + await RemoteConfigService.update(); + } + await Get.find().getPermisos(forceRefresh: forceRefresh); // Forzar re-descarga de los permisos + await Get.find().getNoticias(forceRefresh: forceRefresh); // Forzar re-descarga de las noticias + setState(() => _banners = RemoteConfigService.banners); // Actualizar los banners y se re-renderiza } String get _greetingText { List texts = jsonDecode(RemoteConfigService.greetings); - - Random random = new Random(); - String text = texts[random.nextInt(texts.length)]; - text = text.replaceAll("%name", widget.usuario.primerNombre); - return text; + return texts[Random().nextInt(texts.length)]; } @override - Widget build(BuildContext context) { - final banners = RemoteConfigService.banners; - - return Scaffold( - appBar: CustomAppBar(title: Text("Inicio")), - drawer: CustomDrawer(usuario: widget.usuario), - floatingActionButton: kDebugMode - ? FloatingActionButton( - onPressed: () { - GradesChangesController.checkIfGradesHasChange(); - }, - tooltip: "Probar notificaciones de notas", - child: Icon( - Icons.notifications, - color: Colors.white, - ), - ) - : null, - body: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container(height: 20), - Container( - padding: EdgeInsets.symmetric(horizontal: 20), - width: double.infinity, - child: MarkdownBody( - data: _greetingText, - styleSheet: MarkdownStyleSheet( - p: Get.textTheme.displayMedium! - .copyWith(fontWeight: FontWeight.normal), + Widget build(BuildContext context) => Scaffold( + appBar: CustomAppBar(title: Text("Inicio")), + drawer: CustomDrawer(), + floatingActionButton: kDebugMode ? FloatingActionButton( + onPressed: () => Get.find().lookForGradeUpdates(), + tooltip: "Probar notificaciones de notas", + child: Icon(Icons.notifications, + color: Colors.white, + ), + ) : null, + body: PullToRefresh( + onRefresh: loadData, + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(height: 20), + Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: SizedBox( + width: double.infinity, + child: FutureBuilder( + future: Preferencia.apodo.get(defaultValue: "N/N"), + initialData: _user?.primerNombre ?? "N/N", + builder: (ctx, snapshot) => MarkdownBody( + data: _greetingText.replaceAll("%name", snapshot.data ?? "N/N"), + styleSheet: MarkdownStyleSheet( + p: Theme.of(context).textTheme.displayMedium!.copyWith(fontWeight: FontWeight.normal), + ), + ), + ), ), ), - ), - Container(height: 20), - PermisosCovidSection(), - Container(height: 20), - QuickMenuSection(), - Container(height: 20), - if (banners.isNotEmpty) ...[ - BannersSection( - banners: banners, - ), - Container(height: 20), + const SizedBox(height: 20), + PermisosCovidSection(), + const SizedBox(height: 20), + const QuickMenuSection(), + const SizedBox(height: 20), + if (_banners.isNotEmpty) ...[ + BannersSection(banners: _banners), + const SizedBox(height: 20), + ], + NoticiasCarruselWidget(), ], - NoticiasSection(), - ], + ), ), ), - ); - } + ), + ); } diff --git a/lib/screens/malla_screen.dart b/lib/screens/malla_screen.dart index ebf3022..b6556d5 100644 --- a/lib/screens/malla_screen.dart +++ b/lib/screens/malla_screen.dart @@ -1,21 +1,19 @@ import 'package:flutter/material.dart'; - -import 'package:get/get.dart'; - import 'package:mi_utem/widgets/custom_app_bar.dart'; class MallaScreen extends StatelessWidget { + @override Widget build(BuildContext context) { return Scaffold( - appBar: CustomAppBar( - title: Text("Malla"), - ), - body: SingleChildScrollView( - padding: EdgeInsets.all(20), - scrollDirection: Axis.horizontal, - child: Image.asset('assets/images/malla.png', - height: Get.mediaQuery.size.height), - )); + appBar: CustomAppBar( + title: Text("Malla"), + ), + body: SingleChildScrollView( + padding: EdgeInsets.all(20), + scrollDirection: Axis.horizontal, + child: Image.asset('assets/images/malla.png', height: MediaQuery.of(context).size.height), + ), + ); } } diff --git a/lib/screens/onboarding/notifications_screen.dart b/lib/screens/onboarding/notifications_screen.dart new file mode 100644 index 0000000..19c7c53 --- /dev/null +++ b/lib/screens/onboarding/notifications_screen.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/preferencia.dart'; +import 'package:mi_utem/screens/main_screen.dart'; +import 'package:mi_utem/services/notification_service.dart'; +import 'package:mi_utem/themes/theme.dart'; +import 'package:mi_utem/widgets/gradient_background.dart'; + +class NotificationsScreen extends StatefulWidget { + const NotificationsScreen({super.key}); + + @override + State createState() => _NotificationsScreenState(); +} + +class _NotificationsScreenState extends State { + + bool _hasAllowedNotifications = false; + + @override + void initState() { + Preferencia.onboardingStep.set("notifications"); + NotificationService.hasAllowedNotifications().then((value) { + if(value) { + setState(() => _hasAllowedNotifications = value); + } + }); + super.initState(); + } + + @override + Widget build(BuildContext context) => GradientBackground( + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(), + Icon(Icons.notifications_active_outlined, size: 100, color: Colors.white), + const SizedBox(height: 50), + Column( + children: [ + Text(_hasAllowedNotifications ? "Muchas Gracias!" : "Permítenos avisarte", + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + Text("Olvídate sobre revisar la app a cada rato, ${_hasAllowedNotifications ? "te avisaremos" : "permítenos avisarte"} cuando tus notas cambien, o existan otras novedades!", + style: TextStyle( + color: Colors.white, + fontSize: 20, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + if(!_hasAllowedNotifications) FilledButton( + onPressed: () async => await NotificationService.requestUserPermissionIfNecessary(context).then((value) => setState(() => _hasAllowedNotifications = value)), + style: ElevatedButton.styleFrom( + backgroundColor: MainTheme.primaryDarkColor, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text("Permitir Notificaciones", + style: TextStyle( + fontSize: 20, + ), + textAlign: TextAlign.center, + ), + ) + ], + ), + + const Spacer(), + + FilledButton( + onPressed: () async { + if(!context.mounted) { + return; + } + await Preferencia.onboardingStep.set("complete"); + Navigator.popUntil(context, (route) => route.isFirst); + final alias = await Preferencia.apodo.get(); + NotificationService.showAnnouncementNotification( + title: alias != null ? "¡Hola $alias! 🎉" : "¡Hola! 🎉", + body: '¡Te damos la bienvenida a la aplicación Mi UTEM! 🚀', + ); + Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => MainScreen())); + }, + style: ElevatedButton.styleFrom( + backgroundColor: MainTheme.primaryColor, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), + maximumSize: const Size(double.infinity, 60), + minimumSize: const Size(double.infinity, 60), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text(_hasAllowedNotifications ? "Finalizar" : "No Gracias, Finalizar", + style: const TextStyle( + fontSize: 20, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 25), + ], + ), + ), + ), + ], + ), + ); +} + diff --git a/lib/screens/onboarding/set_alias_screen.dart b/lib/screens/onboarding/set_alias_screen.dart new file mode 100644 index 0000000..3a96194 --- /dev/null +++ b/lib/screens/onboarding/set_alias_screen.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/preferencia.dart'; +import 'package:mi_utem/screens/main_screen.dart'; +import 'package:mi_utem/screens/onboarding/notifications_screen.dart'; +import 'package:mi_utem/themes/theme.dart'; +import 'package:mi_utem/widgets/gradient_background.dart'; + +class SetAliasScreen extends StatefulWidget { + const SetAliasScreen({super.key}); + + @override + State createState() => _SetAliasScreenState(); +} + +class _SetAliasScreenState extends State { + + final _focusNode = FocusNode(); + final _formKey = GlobalKey(); + final _aliasController = TextEditingController(); + + @override + void initState() { + Preferencia.onboardingStep.get().then((step) { + if(step == null) { + Preferencia.onboardingStep.set("alias"); + } else if (step == 'complete') { + Navigator.push(context, MaterialPageRoute(builder: (ctx) => const MainScreen())); + } else { + Navigator.push(context, MaterialPageRoute(builder: (ctx) => const NotificationsScreen())); + } + }); + Preferencia.apodo.get().then((value) => _aliasController.text = value ?? ''); + super.initState(); + } + + @override + Widget build(BuildContext context) => GradientBackground( + resizeToAvoidBottomInset: false, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(), + Icon(Icons.tag_faces, size: 100, color: Colors.white), + const SizedBox(height: 50), + Column( + children: [ + const Text("Comencemos con tu Apodo", + style: TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const Text("¿Cómo quieres que te llamemos?", + style: TextStyle( + color: Colors.white, + fontSize: 20, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Form( + key: _formKey, + child: TextFormField( + focusNode: _focusNode, + controller: _aliasController, + decoration: InputDecoration( + errorBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.red)), + enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.white)), + focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.white)), + focusedErrorBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.red)), + hintText: "Juanin", + labelText: "Apodo", + labelStyle: const TextStyle( + color: Colors.white, + ), + hintStyle: const TextStyle( + color: Colors.grey, + ), + suffixIcon: _aliasController.text.isNotEmpty == true ? GestureDetector ( + onTap: () => _aliasController.clear(), + behavior: HitTestBehavior.opaque, + child: IconButton( + icon: const Icon(Icons.clear, color: Colors.white), + onPressed: () => _aliasController.clear(), + ), + ) : null, + ), + keyboardType: TextInputType.name, + textCapitalization: TextCapitalization.words, + style: const TextStyle( + color: Colors.white, + ), + autofillHints: const [AutofillHints.nickname], + validator: (value) { + final val = value?.trim() ?? ""; + if(val.isEmpty) { + return "Debes ingresar un apodo."; + } + + if(val.length > 24) { + return "El apodo es muy largo."; + } + + return null; + }, + onTapOutside: (event) => FocusScope.of(context).unfocus(), + ), + ), + ], + ), + + const Spacer(), + + FilledButton( + onPressed: () { + if (_formKey.currentState?.validate() != true) { + return; + } + + Preferencia.apodo.set(_aliasController.text); + Navigator.of(context).push(MaterialPageRoute(builder: (context) => const NotificationsScreen())); + }, + style: ElevatedButton.styleFrom( + backgroundColor: MainTheme.primaryColor, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), + maximumSize: const Size(double.infinity, 60), + minimumSize: const Size(double.infinity, 60), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text("Continuar", + style: TextStyle( + fontSize: 20, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 25), + ], + ), + ), + ), + ], + ), + ); + +} \ No newline at end of file diff --git a/lib/screens/onboarding/welcome_screen.dart b/lib/screens/onboarding/welcome_screen.dart new file mode 100644 index 0000000..2378452 --- /dev/null +++ b/lib/screens/onboarding/welcome_screen.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/models/preferencia.dart'; +import 'package:mi_utem/repositories/asignaturas_repository.dart'; +import 'package:mi_utem/repositories/horario_repository.dart'; +import 'package:mi_utem/repositories/permiso_ingreso_repository.dart'; +import 'package:mi_utem/screens/main_screen.dart'; +import 'package:mi_utem/screens/onboarding/set_alias_screen.dart'; +import 'package:mi_utem/services/carreras_service.dart'; +import 'package:mi_utem/themes/theme.dart'; +import 'package:mi_utem/widgets/gradient_background.dart'; + +class WelcomeScreen extends StatefulWidget { + const WelcomeScreen({super.key}); + + @override + State createState() => _WelcomeScreenState(); +} + +class _WelcomeScreenState extends State { + + @override + void initState() { + Preferencia.onboardingStep.get().then((step) { + if(step == null) { + return; + } else if (step == 'complete') { + Navigator.push(context, MaterialPageRoute(builder: (ctx) => const MainScreen())); + } else { + Navigator.push(context, MaterialPageRoute(builder: (ctx) => const SetAliasScreen())); + } + }); + + // Aprovechamos de utilizar el tiempo que tarde en el onboarding para pre-cargar algunos datos + Get.find().getCarreras().then((carrera) { + final carreraId = carrera?.id; + if(carreraId == null) { + return; + } + + Get.find().getHorario(carreraId, forceRefresh: true); + Get.find().getPermisos(forceRefresh: true); + Get.find().getAsignaturas(carreraId, forceRefresh: true); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) => GradientBackground( + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(), + Container( + width: 100, + height: 100, + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage("assets/launcher_icons/prod/full_icon.png"), + fit: BoxFit.contain, + ), + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + ), + const SizedBox(height: 30), + Column( + children: [ + Text("Te damos la bienvenida a Mi UTEM", + style: TextStyle( + color: Colors.white, + fontSize: 30, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + Text("¡Estás a unos pasos de disfrutar las funcionalidades de Mi UTEM!", + style: TextStyle( + color: Colors.white, + fontSize: 20, + ), + textAlign: TextAlign.center, + ) + ], + ), + + const Spacer(), + + FilledButton( + onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (ctx) => const SetAliasScreen())), + style: ElevatedButton.styleFrom( + backgroundColor: MainTheme.primaryColor, + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), + maximumSize: const Size(double.infinity, 60), + minimumSize: const Size(double.infinity, 60), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text("Comenzar", + style: TextStyle( + fontSize: 20, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 25), + ], + ), + ), + ), + ], + ), + ); +} + diff --git a/lib/screens/perfil/perfil_screen.dart b/lib/screens/perfil/perfil_screen.dart new file mode 100644 index 0000000..e3e5ea1 --- /dev/null +++ b/lib/screens/perfil/perfil_screen.dart @@ -0,0 +1,174 @@ +import 'package:clipboard/clipboard.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/models/exceptions/custom_exception.dart'; +import 'package:mi_utem/models/preferencia.dart'; +import 'package:mi_utem/models/user/user.dart'; +import 'package:mi_utem/services/auth_service.dart'; +import 'package:mi_utem/widgets/custom_app_bar.dart'; +import 'package:mi_utem/widgets/custom_error_widget.dart'; +import 'package:mi_utem/widgets/image/image_view_screen.dart'; +import 'package:mi_utem/widgets/loading/loading_indicator.dart'; +import 'package:mi_utem/widgets/profile_photo.dart'; +import 'package:mi_utem/widgets/snackbar.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class PerfilScreen extends StatefulWidget { + + const PerfilScreen({ + super.key, + }); + + @override + State createState() => _PerfilScreenState(); +} + +class _PerfilScreenState extends State { + + String? apodo; + + @override + void initState() { + Preferencia.apodo.get().then((apodo) => setState(() => this.apodo = apodo)); + super.initState(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: CustomAppBar( + title: Text("Perfil"), + ), + body: SafeArea(child: FutureBuilder( + future: Get.find().getUser(), + builder: (ctx, snapshot) { + if(snapshot.connectionState == ConnectionState.waiting) { + return LoadingIndicator.centeredDefault(); + } + + final user = snapshot.data; + if(snapshot.hasError || !snapshot.hasData || user == null) { + return Center( + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: CustomErrorWidget( + emoji: "\u{1F622}", + title: (snapshot.error is CustomException ? (snapshot.error as CustomException).message : "Ocurrió un error al obtener los estudiantes"), + ), + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Stack( + children: [ + Container( + alignment: Alignment.topCenter, + margin: const EdgeInsets.only(top: 80), + child: Card( + margin: const EdgeInsets.all(20), + child: ListView( + padding: const EdgeInsets.only(bottom: 10, top: 20), + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + ListTile( + title: Text("Nombre", + style: TextStyle(color: Colors.grey), + ), + subtitle: Text("${user.nombreCompletoCapitalizado}", + style: TextStyle( + color: Colors.grey[900], + fontSize: 18, + ), + ), + ), + if(apodo != null) Divider(height: 1), + if(apodo != null) ListTile( + title: Text("Apodo", + style: TextStyle(color: Colors.grey), + ), + subtitle: Text("$apodo", + style: TextStyle( + color: Colors.grey[900], + fontSize: 18, + ), + ), + ), + Divider(height: 1), + ListTile( + title: Text("RUT", + style: TextStyle(color: Colors.grey), + ), + subtitle: Text("${user.rut}", + style: TextStyle( + color: Colors.grey[900], + fontSize: 18, + ), + ), + onLongPress: () async { + await FlutterClipboard.copy(user.rut.toString()); + showTextSnackbar(context, title: "¡Copiado!", message: "Rut copiado al portapapeles"); + }, + ), + if(user.correoUtem != null) Divider(height: 1), + if(user.correoUtem != null) ListTile( + title: Text("Correo Institucional", + style: TextStyle(color: Colors.grey), + ), + subtitle: Text("${user.correoUtem}", + style: TextStyle( + color: Colors.grey[900], + fontSize: 18, + ), + ), + onTap: () => launchUrl(Uri.parse("mailto:${user.correoUtem}")), + onLongPress: () async { + await FlutterClipboard.copy(user.correoUtem!); + showTextSnackbar(context, title: "¡Copiado!", message: "Correo copiado al portapapeles"); + }, + ), + if(user.correoPersonal != null) Divider(height: 1), + if(user.correoPersonal != null) ListTile( + title: Text("Correo Personal", + style: TextStyle(color: Colors.grey), + ), + subtitle: Text("${user.correoPersonal}", + style: TextStyle( + color: Colors.grey[900], + fontSize: 18, + ), + ), + onTap: () => launchUrl(Uri.parse("mailto:${user.correoPersonal}")), + onLongPress: () async { + await FlutterClipboard.copy(user.correoPersonal!); + showTextSnackbar(context, title: "¡Copiado!", message: "Correo copiado al portapapeles"); + }, + ), + ], + ), + ), + ), + Center( + child: ProfilePhoto( + fotoUrl: user.fotoUrl, + iniciales: user.iniciales, + radius: 60, + editable: false, + onImage: (imagenBase64) async { + // Actualizar foto de perfil en miutem + showTextSnackbar(context, title: "¡Listo!", message: "Guardamos tu foto de perfil"); + setState(() {}); + }, + onImageTap: (context, imageProvider) => Navigator.push(context, MaterialPageRoute(builder: (ctx) => ImageViewScreen( + imageProvider: imageProvider, + ))), + ), + ), + ], + ), + ); + }, + )), + ); +} diff --git a/lib/screens/permiso_covid_screen.dart b/lib/screens/permiso_covid_screen.dart index ade51a8..6634da5 100644 --- a/lib/screens/permiso_covid_screen.dart +++ b/lib/screens/permiso_covid_screen.dart @@ -1,245 +1,67 @@ -import 'dart:typed_data'; - -import 'package:barcode_image/barcode_image.dart'; -import 'package:barcode_widget/barcode_widget.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_uxcam/flutter_uxcam.dart'; import 'package:get/get.dart'; -import 'package:image/image.dart' as dartImage; -import 'package:intl/intl.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/controllers/qr_pass_controller.dart'; -import 'package:mi_utem/models/permiso_covid.dart'; -import 'package:mi_utem/models/usuario.dart'; +import 'package:mi_utem/config/logger.dart'; +import 'package:mi_utem/models/exceptions/custom_exception.dart'; +import 'package:mi_utem/models/permiso_ingreso.dart'; +import 'package:mi_utem/repositories/permiso_ingreso_repository.dart'; import 'package:mi_utem/widgets/custom_app_bar.dart'; import 'package:mi_utem/widgets/custom_error_widget.dart'; -import 'package:mi_utem/widgets/field_list_tile.dart'; -import 'package:mi_utem/widgets/image_view_screen.dart'; -import 'package:mi_utem/widgets/loading_indicator.dart'; -import 'package:mi_utem/widgets/profile_photo.dart'; - -class PermisoCovidScreen extends GetView { - const PermisoCovidScreen({ - Key? key, - }) : super(key: key); - - @override - String? get tag => Get.parameters[Routes.passParameter]; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: CustomAppBar(title: Text("Permiso de ingreso")), - body: controller.obx( - (pass) => SingleChildScrollView( - child: LoadedScreen(permiso: pass!), - ), - onLoading: Center( - child: LoadingIndicator( - message: "Esto tardará un poco, paciencia...", - ), - ), - onEmpty: Text('No data found'), - onError: (error) => CustomErrorWidget(), - ), - ); - } -} - -class LoadedScreen extends StatelessWidget { - const LoadedScreen({ - Key? key, - required this.permiso, - }) : super(key: key); - - final PermisoCovid permiso; +import 'package:mi_utem/widgets/loading/loading_indicator.dart'; +import 'package:mi_utem/widgets/permiso_ingreso/qr_card.dart'; +import 'package:mi_utem/widgets/pull_to_refresh.dart'; - _openQr(String heroTag) { - final image = dartImage.Image(500, 500); +class PermisoCovidScreen extends StatefulWidget { + final String passId; - dartImage.fill(image, dartImage.getColor(255, 255, 255)); - drawBarcode( - image, - Barcode.qrCode(), - permiso.codigoQr!, - x: 25, - y: 25, - width: 450, - height: 450, - ); - - Uint8List data = Uint8List.fromList(dartImage.encodePng(image)); - - Get.to( - () => ImageViewScreen( - imageProvider: MemoryImage(data), - heroTag: heroTag, - occlude: true, - ), - routeName: Routes.imageView, - ); - } - - @override - Widget build(BuildContext context) { - final f = new DateFormat('dd/MM/yyyy'); - return Padding( - padding: const EdgeInsets.all(20.0), - child: Card( - child: Column( - children: [ - UsuarioDetalle( - usuario: permiso.usuario!, - ), - Divider(thickness: 1, color: Color(0xFFFEEEEE)), - DetallesPermiso( - campus: permiso.campus, - dependencias: permiso.dependencia, - jornada: permiso.jornada, - vigencia: permiso.vigencia, - motivo: permiso.motivo, - ), - Divider(thickness: 1, color: Color(0xFFFEEEEE)), - Container(height: 20), - Center( - child: InkWell( - onTap: () => _openQr("qr_${permiso.codigoQr!}"), - child: Hero( - tag: "qr_${permiso.codigoQr!}", - child: Container( - color: Colors.white, - padding: EdgeInsets.all(10), - child: OccludeWrapper( - child: BarcodeWidget( - barcode: Barcode.qrCode(), - height: 200, - width: 200, - data: permiso.codigoQr!, - drawText: false, - ), - ), - ), - ), - ), - ), - Container(height: 20), - Text( - "Permiso generado el ${f.format(permiso.fechaSolicitud!)}", - style: Get.textTheme.bodySmall, - ), - Container(height: 20), - ], - ), - ), - ); - } -} - -class UsuarioDetalle extends StatelessWidget { - const UsuarioDetalle({ - Key? key, - required this.usuario, - }) : super(key: key); - - final Usuario usuario; + const PermisoCovidScreen({ + super.key, + required this.passId, + }); @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(20.0), - child: Row( - children: [ - ProfilePhoto(usuario: usuario), - Container(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - usuario.nombre!, - maxLines: 2, - style: Get.textTheme.bodyLarge, - ), - Container(height: 4), - Text( - usuario.rut!.formateado(true), - style: Get.textTheme.bodyMedium, - ), - ], - ), - ), - ], - ), - ); - } + State createState() => _PermisoCovidScreenState(); } -class DetallesPermiso extends StatelessWidget { - const DetallesPermiso({ - Key? key, - this.campus, - this.dependencias, - this.jornada, - this.vigencia, - this.motivo, - }) : super(key: key); +class _PermisoCovidScreenState extends State { - final String? campus, dependencias, jornada, vigencia, motivo; + final PermisoIngresoRepository _permisoIngresoRepository = Get.find(); @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FieldListTile( - padding: EdgeInsets.zero, - title: "Motivo", - value: motivo, - ), - if (campus != null || dependencias != null) Container(height: 20), - if (campus != null || dependencias != null) - Row( - children: [ - Expanded( - child: FieldListTile( - padding: EdgeInsets.zero, - title: "Campus", - value: campus, - ), - ), - Expanded( - child: FieldListTile( - padding: EdgeInsets.zero, - title: "Dependencias", - value: dependencias, - ), - ), - ], - ), - if (jornada != null || vigencia != null) Container(height: 20), - if (jornada != null || vigencia != null) - Row( - children: [ - Expanded( - child: FieldListTile( - padding: EdgeInsets.zero, - title: "Jornada", - value: jornada, - ), - ), - Expanded( - child: FieldListTile( - padding: EdgeInsets.zero, - title: "Vigencia", - value: vigencia, - ), - ), - ], - ), - ], + Widget build(BuildContext context) => Scaffold( + appBar: CustomAppBar(title: Text("Permiso de ingreso")), + body: SafeArea(child: PullToRefresh( + onRefresh: () async { + await _permisoIngresoRepository.getDetallesPermiso(widget.passId, forceRefresh: true); + setState(() {}); + }, + child: FutureBuilder( + future: _permisoIngresoRepository.getDetallesPermiso(widget.passId), + builder: (context, snapshot) { + if (snapshot.hasError) { + final error = snapshot.error is CustomException ? (snapshot.error as CustomException).message : "No sabemos lo que ocurrió. Por favor intenta más tarde."; + logger.e("Error al cargar permiso", snapshot.error); + return CustomErrorWidget(error: error); + } + + final permiso = snapshot.data; + if(!snapshot.hasData) { + return LoadingIndicator.centeredDefault(); + } + + if(permiso == null) { + return const CustomErrorWidget( + title: "Permiso no encontrado", + error: "Lo sentimos, no pudimos encontrar el permiso de ingreso. Por favor intenta más tarde.", + emoji: "\u{1F914}", + ); + } + + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: QRCard(permiso: permiso), + ); + }, ), - ); - } + )), + ); } diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 7cb983b..2e6a10f 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -2,19 +2,29 @@ import 'package:flare_flutter/flare_actor.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/services/notification_service.dart'; +import 'package:mi_utem/models/preferencia.dart'; +import 'package:mi_utem/screens/login_screen/login_screen.dart'; +import 'package:mi_utem/screens/main_screen.dart'; +import 'package:mi_utem/screens/onboarding/welcome_screen.dart'; +import 'package:mi_utem/services/analytics_service.dart'; +import 'package:mi_utem/services/auth_service.dart'; +import 'package:mi_utem/utils/http/functions.dart'; +import 'package:mi_utem/widgets/loading/loading_dialog.dart'; +import 'package:mi_utem/widgets/snackbar.dart'; import 'package:package_info_plus/package_info_plus.dart'; class SplashScreen extends StatefulWidget { - SplashScreen({Key? key}) : super(key: key); + const SplashScreen({ + super.key, + }); @override _SplashScreenState createState() => _SplashScreenState(); } class _SplashScreenState extends State { - var delayed; + + final _authService = Get.find(); @override void initState() { @@ -31,81 +41,80 @@ class _SplashScreenState extends State { ); } - bool _terminoAnimacion = false; + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: Theme.of(context).primaryColor, + body: Stack( + children: [ + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topRight, + end: Alignment.bottomLeft, + colors: [Color(0xff1d8e5c), Color(0xff06607a)], + ), + ), + ), + Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.all(20), + child: Hero( + tag: 'utemLogo', + child: FlareActor("assets/animations/utem.flr", + alignment: Alignment.center, + fit: BoxFit.contain, + animation: "default", + callback: (String val) async { + showLoadingDialog(context); - void _onEndAnimacion() async { - _terminoAnimacion = true; - _cambiarPantalla(); - } + // Revisar si tenemos conexión a internet + bool offlineMode = await isOffline(); + final user = await _authService.getUser(); - void _cambiarPantalla() async { - if (_terminoAnimacion) { - Get.offAllNamed( - Routes.home, - ); - await NotificationService.requestUserPermissionIfNecessary(); - } - } + if(offlineMode) { + Navigator.pop(context); + showTextSnackbar(context, + title: "Error al conectar con la API", + message: user != null ? "La app funcionará en modo Offline. Revisa tu conexión a internet si quieres acceder a todas las funcionalidades." : "Ouch! Parece que no tienes una conexión a internet. Revisa tu conexión e intenta más tarde.", + backgroundColor: Colors.red, + duration: Duration(seconds: 20), + ); - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Get.theme.primaryColor, - body: Stack( - children: [ - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [Color(0xff1d8e5c), Color(0xff06607a)], + if(user == null) { + return; + } + } + + final isLoggedIn = await _authService.isLoggedIn(); + AnalyticsService.removeUser(); + + // Esto nos asegura de que el splash es la única ruta inicial, y resuelve el error de poder volver al login. + Navigator.popUntil(context, (route) => route.isFirst); + + final hasCompletedOnboarding = (await Preferencia.onboardingStep.get()) == "complete"; + Navigator.pushReplacement(context, MaterialPageRoute(builder: (ctx) => isLoggedIn ? (hasCompletedOnboarding ? MainScreen() : WelcomeScreen()) : LoginScreen())); + }, + ), + ), ), ), - ), - Column( - children: [ - Expanded( - child: Padding( - padding: EdgeInsets.all(20), - child: Hero( - tag: 'utemLogo', - child: FlareActor( - "assets/animations/utem.flr", - alignment: Alignment.center, - fit: BoxFit.contain, - animation: "default", - callback: (String val) { - _onEndAnimacion(); - }, - ), + FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) => !snapshot.hasError && snapshot.hasData && snapshot.data != null ? Padding( + padding: const EdgeInsets.all(10), + child: Text("Versión: ${snapshot.data?.version}", + style: TextStyle( + color: Colors.white.withOpacity(0.5), ), + textAlign: TextAlign.center, ), - ), - FutureBuilder( - future: PackageInfo.fromPlatform(), - builder: (context, snapshot) { - if (!snapshot.hasError && - snapshot.hasData && - snapshot.data != null) { - PackageInfo packageInfo = snapshot.data!; - return Container( - padding: EdgeInsets.all(10), - child: Text( - "Versión: ${packageInfo.version}", - style: TextStyle( - color: Colors.white.withOpacity(0.5), - ), - ), - ); - } else { - return Container(); - } - }, - ), - ], - ), - ], - ), - ); - } + ) : Container(), + ), + ], + ), + ], + ), + ); } diff --git a/lib/screens/usuario_screen.dart b/lib/screens/usuario_screen.dart deleted file mode 100644 index 33e7fdc..0000000 --- a/lib/screens/usuario_screen.dart +++ /dev/null @@ -1,345 +0,0 @@ -import 'dart:core'; - -import 'package:clipboard/clipboard.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/models/asignatura.dart'; -import 'package:mi_utem/models/usuario.dart'; -import 'package:mi_utem/services/docentes_service.dart'; -import 'package:mi_utem/services/perfil_service.dart'; -import 'package:mi_utem/services/review_service.dart'; -import 'package:mi_utem/widgets/custom_app_bar.dart'; -import 'package:mi_utem/widgets/custom_error_widget.dart'; -import 'package:mi_utem/widgets/image_view_screen.dart'; -import 'package:mi_utem/widgets/loading_dialog.dart'; -import 'package:mi_utem/widgets/loading_indicator.dart'; -import 'package:mi_utem/widgets/profile_photo.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class UsuarioScreen extends StatefulWidget { - final int tipo; - final Map? query; - final Asignatura? asignatura; - UsuarioScreen({Key? key, this.tipo = 0, this.query, this.asignatura}) - : super(key: key); - - @override - _UsuarioScreenState createState() => _UsuarioScreenState(); -} - -class _UsuarioScreenState extends State { - Future? _usuarioFuture; - Usuario? _usuario; - - @override - void initState() { - ReviewService.addScreen("UsuarioScreen"); - super.initState(); - _usuarioFuture = _getUsuario(); - } - - Future _getUsuario() async { - try { - Usuario usuario; - if (widget.tipo == 2) { - print(widget.query); - if (widget.asignatura == null) { - usuario = - await DocentesService.traerUnDocente(widget.query!["nombre"]); - } else { - usuario = await DocentesService.asignarUnDocente( - widget.query!["nombre"], - widget.asignatura!.codigo, - widget.asignatura!.nombre); - } - - setState(() { - _usuario = usuario; - }); - } else { - usuario = PerfilService.getLocalUsuario(); - setState(() { - _usuario = usuario; - }); - } - - return usuario; - } catch (e) { - throw e; - } - } - - Future _changeFoto(String imagen) async { - Get.dialog( - LoadingDialog(), - barrierDismissible: false, - ); - - try { - Usuario usuario = await PerfilService.changeFoto(imagen); - Get.back(); - - setState(() { - _usuario = usuario; - }); - - return; - } catch (e) { - Get.back(); - print("Error cambiando la imagen ${e.toString()}"); - Get.snackbar( - "Error", - "No se pudo cambiar la foto", - colorText: Colors.white, - backgroundColor: Get.theme.primaryColor, - snackPosition: SnackPosition.BOTTOM, - margin: EdgeInsets.all(20), - ); - } - } - - List get _datosPersonales { - List lista = []; - if (_usuario != null) { - if (_usuario!.nombre != null && _usuario!.nombre!.isNotEmpty) { - lista.add(ListTile( - title: Text( - "Nombre", - style: TextStyle( - color: Colors.grey, - ), - ), - subtitle: Text( - _usuario!.nombreCompleto!, - style: TextStyle( - color: Colors.grey[900], - fontSize: 18, - ), - ), - )); - } else { - if (_usuario!.nombres != null && _usuario!.nombres!.isNotEmpty) { - lista.add( - ListTile( - title: Text( - "Nombres", - style: TextStyle( - color: Colors.grey, - ), - ), - subtitle: Text( - _usuario!.nombres!, - style: TextStyle( - color: Colors.grey[900], - fontSize: 18, - ), - ), - ), - ); - } - - if (_usuario!.apellidos != null && _usuario!.apellidos!.isNotEmpty) { - lista.add(Divider(height: 1)); - lista.add( - ListTile( - title: Text( - "Apellidos", - style: TextStyle( - color: Colors.grey, - ), - ), - subtitle: Text( - _usuario!.apellidos!, - style: TextStyle( - color: Colors.grey[900], - fontSize: 18, - ), - ), - ), - ); - } - } - - if (_usuario!.correoUtem != null && _usuario!.correoUtem!.isNotEmpty) { - lista.add(Divider(height: 1)); - lista.add(ListTile( - title: Text( - "Correo", - style: TextStyle( - color: Colors.grey, - ), - ), - onLongPress: widget.tipo != 0 - ? () async { - await FlutterClipboard.copy(_usuario!.correoUtem!); - Get.snackbar( - "¡Copiado!", - "Correo copiado al portapapeles", - colorText: Colors.white, - backgroundColor: Get.theme.primaryColor, - snackPosition: SnackPosition.BOTTOM, - margin: EdgeInsets.all(20), - ); - } - : null, - onTap: widget.tipo != 0 - ? () async { - await launchUrl( - Uri.parse( - "mailto:${_usuario!.correoUtem}", - ), - ); - } - : null, - subtitle: Text( - _usuario!.correoUtem ?? "", - style: TextStyle( - color: Colors.grey[900], - fontSize: 18, - ), - ), - )); - } - if (_usuario!.correoPersonal != null && - _usuario!.correoPersonal!.isNotEmpty) { - lista.add(Divider(height: 1)); - lista.add( - ListTile( - title: Text( - "Correo", - style: TextStyle( - color: Colors.grey, - ), - ), - onLongPress: widget.tipo != 0 - ? () async { - await FlutterClipboard.copy(_usuario!.correoPersonal!); - Get.snackbar( - "¡Copiado!", - "Correo copiado al portapapeles", - colorText: Colors.white, - backgroundColor: Get.theme.primaryColor, - snackPosition: SnackPosition.BOTTOM, - margin: EdgeInsets.all(20), - ); - } - : null, - onTap: widget.tipo != 0 - ? () async { - await launchUrl( - Uri.parse( - "mailto:${_usuario!.correoPersonal}", - ), - ); - } - : null, - subtitle: Text( - _usuario!.correoPersonal ?? "", - style: TextStyle( - color: Colors.grey[900], - fontSize: 18, - ), - ), - ), - ); - } - - if (widget.tipo == 0 && _usuario!.rut != null) { - lista.add(Divider(height: 1)); - lista.add(ListTile( - title: Text( - "RUT", - style: TextStyle( - color: Colors.grey, - ), - ), - subtitle: Text( - _usuario!.rut!.formateado(true), - style: TextStyle( - color: Colors.grey[900], - fontSize: 18, - ), - ), - )); - } - } - - return lista; - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: CustomAppBar( - title: Text(widget.tipo == 0 ? "Perfil" : widget.query!["nombre"]), - ), - body: FutureBuilder( - future: _usuarioFuture, - builder: (context, snapshot) { - if (snapshot.hasError) { - return CustomErrorWidget( - title: "Ocurrió un error al obtener el perfil", - error: snapshot.error); - } else { - if (snapshot.hasData) { - return SingleChildScrollView( - padding: EdgeInsets.symmetric(vertical: 20), - child: Stack( - children: [ - Container( - alignment: Alignment.topCenter, - margin: EdgeInsets.only(top: 80), - child: Card( - margin: EdgeInsets.all(20), - child: ListView( - padding: EdgeInsets.only(bottom: 10, top: 20), - shrinkWrap: true, - physics: ClampingScrollPhysics(), - children: _datosPersonales, - ), - ), - ), - Center( - child: ProfilePhoto( - usuario: _usuario, - radius: 60, - editable: widget.tipo == 0, - onImage: widget.tipo == 0 - ? (image) { - _changeFoto(image); - } - : null, - onImageTap: (context, imageProvider) { - Get.to( - () => ImageViewScreen( - imageProvider: imageProvider, - ), - routeName: Routes.imageView, - ); - }), - ), - ], - ), - ); - } else { - return Container( - padding: EdgeInsets.all(20), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Center( - child: LoadingIndicator(), - ), - ), - ], - ), - ); - } - } - }, - ), - ); - } -} diff --git a/lib/service_manager.dart b/lib/service_manager.dart new file mode 100644 index 0000000..d3f50ca --- /dev/null +++ b/lib/service_manager.dart @@ -0,0 +1,55 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/config/logger.dart'; +import 'package:mi_utem/controllers/calculator_controller.dart'; +import 'package:mi_utem/controllers/horario_controller.dart'; +import 'package:mi_utem/repositories/asignaturas_repository.dart'; +import 'package:mi_utem/repositories/auth_repository.dart'; +import 'package:mi_utem/repositories/carreras_repository.dart'; +import 'package:mi_utem/repositories/credentials_repository.dart'; +import 'package:mi_utem/repositories/grades_repository.dart'; +import 'package:mi_utem/repositories/horario_repository.dart'; +import 'package:mi_utem/repositories/noticias_repository.dart'; +import 'package:mi_utem/repositories/permiso_ingreso_repository.dart'; +import 'package:mi_utem/services/auth_service.dart'; +import 'package:mi_utem/services/carreras_service.dart'; +import 'package:mi_utem/services/grades_service.dart'; + +Future registerServices() async { + /* Repositorios (Para conectarse a la REST Api o servicios locales) */ + Get.lazyPut(() => AuthRepository()); + Get.lazyPut(() => AsignaturasRepository()); + Get.lazyPut(() => CredentialsRepository(), fenix: true); + Get.lazyPut(() => CarrerasRepository()); + Get.lazyPut(() => GradesRepository()); + Get.lazyPut(() => PermisoIngresoRepository(), fenix: true); + Get.lazyPut(() => NoticiasRepository()); + Get.lazyPut(() => HorarioRepository(), fenix: true); + + /* Servicios (Para procesar datos REST) */ + Get.lazyPut(() => AuthService()); + Get.lazyPut(() => CarrerasService()); + Get.lazyPut(() => GradesService()); + + /* Controladores (Para procesar datos de interfaz) */ + Get.lazyPut(() => HorarioController(), fenix: true); + Get.lazyPut(() => CalculatorController(), fenix: true); + + final credentialsRepository = Get.find(); + if(!await credentialsRepository.hasCredentials()) { + return; + } + + String? email = (await credentialsRepository.getCredentials())?.email; + if(email == null) { + return; + } + + if(!email.contains("@")) { + email += "@utem.cl"; + } + + logger.d("[ServiceManager]: ID de usuario: ${md5.convert(utf8.encode(email)).toString()} ($email)"); +} \ No newline at end of file diff --git a/lib/services/analytics_service.dart b/lib/services/analytics_service.dart index 807356b..5cf7b3b 100644 --- a/lib/services/analytics_service.dart +++ b/lib/services/analytics_service.dart @@ -1,119 +1,24 @@ import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:flutter_uxcam/flutter_uxcam.dart'; -import 'package:mi_utem/models/carrera.dart'; -import 'package:mi_utem/models/usuario.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; class AnalyticsService { - static Future setUser( - Usuario user, - ) async { - await FirebaseAnalytics.instance.setUserId( - id: user.correoUtem, - ); - await FlutterUxcam.setUserIdentity(user.correoUtem); - - if (user.rut != null) { - await FirebaseAnalytics.instance.setUserProperty( - name: "rut", - value: user.rut?.formateado(false), - ); - - await FlutterUxcam.setUserProperty( - "rut", - user.rut!.formateado(false), - ); - } - - if (user.nombre != null) { - await FirebaseAnalytics.instance.setUserProperty( - name: "name", - value: user.nombre, - ); - - await FlutterUxcam.setUserProperty( - "name", - user.nombre!, - ); - } - - if (user.apellidos != null) { - await FirebaseAnalytics.instance.setUserProperty( - name: "last_name", - value: user.apellidos, - ); - - await FlutterUxcam.setUserProperty( - "last_name", - user.apellidos!, - ); - } - - await Sentry.configureScope( - (scope) => scope.setUser( - SentryUser( - id: user.correoUtem, - email: user.correoPersonal, - name: user.nombreCompleto, - data: { - "rut": user.rut?.formateado(false), - }, - ipAddress: "{{auto}}", - ), - ), - ); - } - - static Future setCarreraToUser(Carrera carrera) async { - if (carrera.nombre != null) { - await FirebaseAnalytics.instance.setUserProperty( - name: "carreraActiva", - value: carrera.nombre!, - ); - - await FlutterUxcam.setUserProperty( - "carreraActiva", - carrera.nombre!, - ); - } - - if (carrera.estado != null) { - await FirebaseAnalytics.instance.setUserProperty( - name: "estadoCarreraActiva", - value: carrera.estado!, - ); - - await FlutterUxcam.setUserProperty( - "estadoCarreraActiva", - carrera.estado!, - ); - } - } static Future removeUser() async { await FirebaseAnalytics.instance.setUserId(id: null); await FlutterUxcam.setUserIdentity(null); - await Sentry.configureScope( - (scope) => scope.setUser(null), - ); + await Sentry.configureScope((scope) => scope.setUser(null)); } - static Future logEvent( - String name, { - Map? parameters, - }) async { - await FirebaseAnalytics.instance.logEvent( - name: name, - parameters: parameters, - ); + static Future logEvent(String name, {Map? parameters}) async { + await FirebaseAnalytics.instance.logEvent(name: name, parameters: parameters); if (parameters != null) { - await FlutterUxcam.logEventWithProperties( - name, - parameters, - ); + await FlutterUxcam.logEventWithProperties(name, parameters); } else { await FlutterUxcam.logEvent(name); } + + Sentry.metrics().increment(name, tags: parameters?.map((key, value) => MapEntry(key, value.toString()))); } } diff --git a/lib/services/asignaturas_service.dart b/lib/services/asignaturas_service.dart deleted file mode 100644 index 9bb7352..0000000 --- a/lib/services/asignaturas_service.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:dio_http_cache/dio_http_cache.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:mi_utem/models/asignatura.dart'; -import 'package:mi_utem/services/perfil_service.dart'; -import 'package:mi_utem/utils/dio_miutem_client.dart'; - -class AsignaturasService { - static final Dio _dio = DioMiUtemClient.authDio; - static final GetStorage box = GetStorage(); - - static Future> getAsignaturas( - String carreraId, { - bool forceRefresh = false, - }) async { - final uri = "/v1/carreras/$carreraId/asignaturas"; - final user = PerfilService.getLocalUsuario(); - - try { - Response response = await _dio.get( - uri, - options: buildCacheOptions( - Duration(days: 7), - forceRefresh: forceRefresh, - subKey: user.rut?.numero.toString(), - ), - ); - - List asignaturas = Asignatura.fromJsonList(response.data); - - return asignaturas; - } on DioError catch (e) { - print(e.message); - throw e; - } - } - - static Future getDetalleAsignatura( - String? codigo, { - bool forceRefresh = false, - }) async { - final uri = "/v1/asignaturas/$codigo"; - final user = PerfilService.getLocalUsuario(); - - try { - Response response = await _dio.get( - uri, - options: buildCacheOptions( - Duration(days: 7), - forceRefresh: forceRefresh, - subKey: user.rut?.numero.toString(), - ), - ); - - Asignatura asignatura = Asignatura.fromJson(response.data); - - return asignatura; - } on DioError catch (e) { - print(e.message); - throw e; - } - } -} diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 29d756f..1283d88 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -1,150 +1,183 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:mi_utem/models/usuario.dart'; -import 'package:mi_utem/services/perfil_service.dart'; -import 'package:mi_utem/utils/dio_miutem_client.dart'; +import 'dart:convert'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/config/logger.dart'; +import 'package:mi_utem/config/secure_storage.dart'; +import 'package:mi_utem/models/preferencia.dart'; +import 'package:mi_utem/models/user/credential.dart'; +import 'package:mi_utem/models/user/user.dart'; +import 'package:mi_utem/repositories/auth_repository.dart'; +import 'package:mi_utem/repositories/credentials_repository.dart'; +import 'package:mi_utem/screens/login_screen/login_screen.dart'; +import 'package:mi_utem/services/notification_service.dart'; +import 'package:mi_utem/utils/http/http_client.dart'; +import 'package:mi_utem/utils/utils.dart'; class AuthService { - static final Dio _dio = DioMiUtemClient.initDio; - static final GetStorage box = GetStorage(); - static Future login(String? correo, String? contrasenia, - [bool guardar = false]) async { - String uri = "/v1/auth"; + AuthRepository _authRepository = Get.find(); + CredentialsRepository _credentialsService = Get.find(); + + Future isFirstTime() async => (await Preferencia.lastLogin.exists()) == false; + + Future isLoggedIn({ bool forceRefresh = false }) async { + final credentials = await _getCredential(); + if(credentials == null) { + logger.d("[AuthService#isLoggedIn]: No se encontraron credenciales."); + return false; + } + + final user = await getUser(); + final userToken = user?.token; + if(user == null || userToken == null) { + logger.d("[AuthService#isLoggedIn]: Usuario o token nulo (user?: ${user == null}, token?: ${userToken == null})"); + return false; + } + + final now = DateTime.now(); + final lastLoginDate = let(await Preferencia.lastLogin.get(), (String _lastLogin) => DateTime.tryParse(_lastLogin)) ?? now; + final difference = now.difference(lastLoginDate); + final offlineMode = (await Preferencia.isOffline.get()) == "true"; + if((difference.inMinutes < 4 && now != lastLoginDate && !forceRefresh) || offlineMode) { + return true; + } try { - final FlutterSecureStorage storage = new FlutterSecureStorage(); - - dynamic data = {'correo': correo, 'contrasenia': contrasenia}; - - Response response = await _dio.post(uri, data: data); - - Usuario usuario = Usuario(); - if (response.statusCode == 200) { - usuario = Usuario.fromJson(response.data); - box.write('token', usuario.token!); - if (usuario.nombres != null) { - box.write('nombres', usuario.nombres!); - } - if (usuario.apellidos != null) { - box.write('apellidos', usuario.apellidos!); - } - if (usuario.nombre != null) { - box.write('nombre', usuario.nombre!); - } - if (usuario.fotoUrl != null) { - box.write('fotoUrl', usuario.fotoUrl!); - } - if (usuario.correoUtem != null) { - box.write('correoUtem', usuario.correoUtem!); - } - if (usuario.correoPersonal != null) { - box.write('correoPersonal', usuario.correoPersonal!); - } - if (usuario.rut?.numero != null) { - box.write('rut', usuario.rut!.numero!); - } - box.write('esAntiguo', true); - - if (guardar) { - await storage.write(key: "contrasenia", value: contrasenia); - } - } - return usuario; - } on DioError catch (e) { - print(e.message); - throw e; + final token = await _authRepository.refresh(token: userToken, credentials: credentials); + await setUser(user.copyWith(token: token)); + Preferencia.lastLogin.set(now.toIso8601String()); + return true; } catch (e) { - throw e; + logger.e("[AuthService#isLoggedIn]: Error al refrescar token", e); } + + return false; } - static Future esPrimeraVez() async { - try { - bool? esAntiguo = box.read("esAntiguo"); - if (esAntiguo == null) { - return true; - } else { - return !esAntiguo; - } - } catch (e) { - print(e); - return false; + Future login() async { + final credentials = await _getCredential(); + if(credentials == null) { + return; } + + final user = await _authRepository.auth(credentials: credentials); + + await setUser(user); + Preferencia.lastLogin.set(DateTime.now().toIso8601String()); } - static bool isLoggedIn() { - try { - String? token = box.read("token"); - bool isLoggedIn = token != null && token.isNotEmpty; + Future logout({BuildContext? context}) async { + await setUser(null); + await _credentialsService.setCredentials(null); + await Preferencia.onboardingStep.delete(); + await Preferencia.lastLogin.delete(); + await Preferencia.apodo.delete(); + await HttpClient.clearCache(); + + if(context != null) { + Navigator.popUntil(context, (route) => route.isFirst); + Navigator.pushReplacement(context, MaterialPageRoute(builder: (ctx) => LoginScreen())); + } + } - return isLoggedIn; - } catch (e) { - print(e); - return false; + Future getUser() async { + final data = await secureStorage.read(key: "user"); + if(data == null || data == "null") { + return null; } + + return User.fromJson(jsonDecode(data) as Map); } - static Future logOut() async { - try { - final FlutterSecureStorage storage = new FlutterSecureStorage(); - - box.remove("token"); - box.remove("correo"); - box.remove("nombres"); - box.remove("nombre"); - box.remove("apellidos"); - box.remove("fotoUrl"); - box.remove("rut"); - await storage.deleteAll(); - try { - await PerfilService.deleteFcmToken(); - } catch (e) {} - } catch (e) { - print(e.toString()); - throw e; + Future setUser(User? user) async => await secureStorage.write(key: "user", value: user.toString()); + + Future _getCredential() async { + final hasCredential = await _credentialsService.hasCredentials(); + final credential = await _credentialsService.getCredentials(); + if(!hasCredential || credential == null) { + return null; } + + return credential; } - static Future refreshToken() async { - String uri = "/v1/auth/refresh"; + Future updateProfilePicture(String image) async { + final user = await getUser(); + if(user == null) { + return null; + } - try { - final FlutterSecureStorage secureStorage = new FlutterSecureStorage(); + final _fotoUrl = _authRepository.updateProfilePicture(image: image); + final jsonUser = user.toJson(); + jsonUser["fotoUrl"] = _fotoUrl; + await setUser(User.fromJson(jsonUser)); + return user; + } - String? correo = box.read("correoUtem"); - String? contrasenia = await secureStorage.read(key: "contrasenia"); + Future saveFCMToken() async { + final user = await this.getUser(); + if(user == null) { + return; + } - if (correo != null && contrasenia != null) { - dynamic data = {'correo': correo, 'contrasenia': contrasenia}; + String? fcmToken; + try { + fcmToken = await NotificationService.fcm.requestFirebaseAppToken(); + } catch (e) { + logger.e("[AuthService#saveFCMToken]: Error al obtener FCM Token", e); + return; + } - Response response = await DioMiUtemClient.authDio.post(uri, data: data); + final usersCollection = FirebaseFirestore.instance.collection('usuarios'); - String token = response.data['token']; - _storeToken(token); + try { + await this.deleteFCMToken(); + } catch (e) { + logger.e("[AuthService#saveFCMToken]: Error al eliminar FCM Token", e); + } - return token; - } - throw Exception("No se pudo refrescar el token"); + try { + usersCollection.doc(user.rut?.rut.toString()).set({ + 'fcmTokens': FieldValue.arrayUnion([fcmToken]), + }, SetOptions(merge: true)); } catch (e) { - print(e.toString()); - rethrow; + logger.e("[AuthService#saveFCMToken]: Error al guardar FCM Token", e); } } - static void _storeToken(String token) async { - box.write('token', token); - } + Future deleteFCMToken() async { + String? fcmToken; + try { + fcmToken = await NotificationService.fcm.requestFirebaseAppToken(); + } catch (e) { + logger.e("[AuthService#deleteFCMToken]: Error al obtener FCM Token", e); + return; + } - static String getToken() { - final token = box.read('token'); - if (token == null) throw Exception("No se ha encontrado el token"); + final usersCollection = FirebaseFirestore.instance.collection('usuarios'); - return token; - } + QuerySnapshot> snapshotRepeated; + try { + snapshotRepeated = await usersCollection.where('fcmTokens', arrayContains: fcmToken).get(); + } on FirebaseException catch(e) { + print(e); + return; + } catch (e) { + logger.e("[AuthService#deleteFCMToken]: Error al obtener usuarios con FCM Token", e); + return; + } - static void invalidateToken() async { - box.remove('token'); + try { + for(final doc in snapshotRepeated.docs) { + doc.reference.set({ + "fcmTokens": FieldValue.arrayRemove([fcmToken]), + }, SetOptions(merge: true)); + } + } catch (e) { + logger.e("[AuthService#deleteFCMToken]: Error al eliminar FCM Token", e); + } } -} + +} \ No newline at end of file diff --git a/lib/services/background_service.dart b/lib/services/background_service.dart index 207e5c0..ea3f9b2 100644 --- a/lib/services/background_service.dart +++ b/lib/services/background_service.dart @@ -1,12 +1,33 @@ -import 'dart:async'; import 'package:background_fetch/background_fetch.dart'; -import 'package:mi_utem/controllers/grades_changes_controller.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/config/logger.dart'; +import 'package:mi_utem/repositories/asignaturas_repository.dart'; +import 'package:mi_utem/repositories/carreras_repository.dart'; +import 'package:mi_utem/repositories/horario_repository.dart'; +import 'package:mi_utem/repositories/permiso_ingreso_repository.dart'; +import 'package:mi_utem/services/auth_service.dart'; +import 'package:mi_utem/services/carreras_service.dart'; +import 'package:mi_utem/services/grades_service.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +final _backgroundFetchConfig = BackgroundFetchConfig( + minimumFetchInterval: 15, + startOnBoot: true, + stopOnTerminate: false, + enableHeadless: true, + requiresBatteryNotLow: false, + requiresCharging: false, + requiresStorageNotLow: false, + requiresDeviceIdle: false, + + // Se necesita conexión a internet para funcionar. + requiredNetworkType: NetworkType.ANY, +); + class BackgroundController { -// [Android-only] This "Headless Task" is run when the Android app is terminated with `enableHeadless: true` -// Be sure to annotate your callback function to avoid issues in release mode on Flutter >= 3.3.0 + // [Android-only] This "Headless Task" is run when the Android app is terminated with `enableHeadless: true` + // Be sure to annotate your callback function to avoid issues in release mode on Flutter >= 3.3.0 @pragma('vm:entry-point') static void backgroundFetchHeadlessTask(HeadlessTask task) async { String taskId = task.taskId; @@ -20,39 +41,115 @@ class BackgroundController { } class BackgroundService { - static Future initAndStart() async { - BackgroundFetch.registerHeadlessTask( - BackgroundController.backgroundFetchHeadlessTask, - ); - await BackgroundFetch.configure( - BackgroundFetchConfig( - minimumFetchInterval: 30, - startOnBoot: true, - stopOnTerminate: false, - enableHeadless: true, - requiresBatteryNotLow: false, - requiresCharging: false, - requiresStorageNotLow: false, - requiresDeviceIdle: false, - requiredNetworkType: NetworkType.NONE, - ), - (String taskId) async { - await GradesChangesController.checkIfGradesHasChange(); - - BackgroundFetch.finish(taskId); - }, - (String taskId) async { - Sentry.captureMessage( - "BackgroundFetch task timeout $taskId", - level: SentryLevel.warning, - ); - BackgroundFetch.finish(taskId); - }, - ); + static Future initAndStart() async { + BackgroundFetch.registerHeadlessTask(BackgroundController.backgroundFetchHeadlessTask); + await BackgroundFetch.configure(_backgroundFetchConfig, (taskId) => Sentry.metrics().timing("BackgroundFetch_$taskId", + function: () async => await _onFetch(taskId), + unit: DurationSentryMeasurementUnit.milliSecond, + ), _onTimeout); - BackgroundFetch.start().then((int status) {}).catchError((e, stackTrace) { + BackgroundFetch.start().then((_) {}).catchError((e, stackTrace) { Sentry.captureException(e, stackTrace: stackTrace); }); } + + static _onFetch(String taskId) async { + final init = DateTime.now(); + var now = init; + logger.d("[BackgroundFetch]: Se ejecutó la tarea '$taskId' (${now.toIso8601String()})"); + + // Refresca el token de autenticación + bool loggedIn = await Get.find().isLoggedIn(forceRefresh: true); + if(!loggedIn) { + logger.d("[BackgroundFetch]: No se pudo refrescar el token de autenticación"); + BackgroundFetch.finish(taskId); + return; + } + logger.d("[BackgroundFetch]: Se refrescó el token de autenticación, tomó ${DateTime.now().difference(now).inMilliseconds} ms"); + now = DateTime.now(); + + // Refresca las carreras + now = await refrescarCarreras(now); + + // Actualiza el horario + now = await refrescarHorario(now); + + // Revisa si hubo un cambio en las notas + now = await notificarCambiosNotas(now); + + // Actualiza los permisos de ingreso + now = await refrescarPermisos(now); + + // Actualiza los datos de las asignaturas + now = await refrescarAsignaturasYEstudiantes(now); + + logger.d("[BackgroundFetch]: Se terminó la tarea '$taskId', tomó ${DateTime.now().difference(init).inMilliseconds} ms"); + } + + static Future refrescarHorario(DateTime now) async { + try { + final carreraId = (await Get.find().getCarreras())?.id; + if(carreraId != null) { + await Get.find().getHorario(carreraId, forceRefresh: true); + } + } catch(_){} + logger.d("[BackgroundFetch]: Se refrescó el horario, tomó ${DateTime.now().difference(now).inMilliseconds} ms"); + now = DateTime.now(); + return now; + } + + static Future refrescarAsignaturasYEstudiantes(DateTime now) async { + try { + final carreraId = (await Get.find().getCarreras())?.id; + if(carreraId != null) { + AsignaturasRepository asignaturasRepository = Get.find(); + final asignaturas = await asignaturasRepository.getAsignaturas(carreraId, forceRefresh: true) ?? []; + for(final asignatura in asignaturas) { + await asignaturasRepository.getEstudiantesAsignatura(asignatura, forceRefresh: true); + } + } + } catch(_){} + logger.d("[BackgroundFetch]: Se refrescaron los datos de las carreras y asignaturas, tomó ${DateTime.now().difference(now).inMilliseconds} ms"); + now = DateTime.now(); + return now; + } + + static Future refrescarPermisos(DateTime now) async { + try { + PermisoIngresoRepository permisoIngresoRepository = Get.find(); + final permisos = await permisoIngresoRepository.getPermisos(forceRefresh: true); + for(final permiso in permisos) { + final id = permiso.id; + if(id == null) continue; + await permisoIngresoRepository.getDetallesPermiso(id, forceRefresh: true); + } + } catch (_){} + logger.d("[BackgroundFetch]: Se refrescaron los permisos de ingreso, tomó ${DateTime.now().difference(now).inMilliseconds} ms"); + now = DateTime.now(); + return now; + } + + static Future notificarCambiosNotas(DateTime now) async { + await Get.find().lookForGradeUpdates(); + logger.d("[BackgroundFetch]: Se revisaron las notas, tomó ${DateTime.now().difference(now).inMilliseconds} ms"); + now = DateTime.now(); + return now; + } + + static Future refrescarCarreras(DateTime now) async { + await Get.find().getCarreras(forceRefresh: true); + logger.d("[BackgroundFetch]: Se refrescaron las carreras, tomó ${DateTime.now().difference(now).inMilliseconds} ms"); + now = DateTime.now(); + return now; + } + + static _onTimeout(String taskId) async { + logger.w("Se agotó el tiempo de espera para la tarea '$taskId'"); + Sentry.captureMessage("Se agotó el tiempo de espera para la tarea '$taskId'", + level: SentryLevel.warning, + ); + BackgroundFetch.finish(taskId); + } } + diff --git a/lib/services/carreras_service.dart b/lib/services/carreras_service.dart index 3326442..35e999a 100644 --- a/lib/services/carreras_service.dart +++ b/lib/services/carreras_service.dart @@ -1,27 +1,39 @@ -import 'package:dio/dio.dart'; -import 'package:dio_http_cache/dio_http_cache.dart'; + +import 'package:get/get.dart'; +import 'package:mi_utem/config/logger.dart'; import 'package:mi_utem/models/carrera.dart'; -import 'package:mi_utem/services/perfil_service.dart'; -import 'package:mi_utem/utils/dio_miutem_client.dart'; +import 'package:mi_utem/repositories/carreras_repository.dart'; + +class CarrerasService { + + final _carrerasRepository = Get.find(); + + List carreras = []; -class CarreraService { - static final Dio _dio = DioMiUtemClient.authDio; + Carrera? selectedCarrera; - static Future> getCarreras({bool forceRefresh = false}) async { - const uri = "/v1/carreras"; - final user = PerfilService.getLocalUsuario(); + Future getCarreras({ bool forceRefresh = false }) async { + logger.d("[CarrerasService#getCarreras]: Obteniendo carreras..."); + final _carreras = await _carrerasRepository.getCarreras(forceRefresh: forceRefresh); - Response response = await _dio.get( - uri, - options: buildCacheOptions( - Duration(days: 7), - forceRefresh: forceRefresh, - subKey: user.rut?.numero.toString(), - ), - ); + carreras.clear(); + carreras.addAll(_carreras); + autoSelectCarreraActiva(); + return selectedCarrera; + } + + void changeSelectedCarrera(Carrera carrera) => selectedCarrera = carrera; - List carreras = Carrera.fromJsonList(response.data); + void autoSelectCarreraActiva() { + final estados = ["Regular", "Causal de Eliminacion"] + .reversed + .map((e) => e.toLowerCase()) + .toList(); - return carreras; + carreras.sort((a,b) => estados.indexOf(b.estado!.toLowerCase()).compareTo(estados.indexOf(a.estado!.toLowerCase()))); + final carreraActiva = carreras.first; + + changeSelectedCarrera(carreraActiva); } -} + +} \ No newline at end of file diff --git a/lib/services/docentes_service.dart b/lib/services/docentes_service.dart index 888ff4b..9cf6d0c 100644 --- a/lib/services/docentes_service.dart +++ b/lib/services/docentes_service.dart @@ -1,96 +1,92 @@ -import 'package:dio/dio.dart'; -import 'package:http/http.dart' as http; - -import 'package:mi_utem/models/usuario.dart'; -import 'package:mi_utem/utils/dio_docente_client.dart'; +import 'package:mi_utem/models/user/user.dart'; class DocentesService { - static final Dio _dio = DioDocenteClient.initDio; - - static Future generarImagenPerfil(Usuario usuario) async { - String baseUrl = "https://mi.utem.cl/static/interdocs/fotos/"; - List formatos = [".jpg", ".jpeg", ".png", ".gif"]; - - String imageUrl = "$baseUrl${usuario.rut!.numero}${formatos[0]}"; - - for (var formato in formatos) { - String actualImageUrl = "$baseUrl${usuario.rut!.numero}$formato"; - final imageResponse = await http.head(Uri.parse(actualImageUrl)); - - if (imageResponse.statusCode == 200) { - imageUrl = actualImageUrl; - return imageUrl; - } - } - return imageUrl; + static Future generarImagenPerfil(User user) async { + // String baseUrl = "https://mi.utem.cl/static/interdocs/fotos/"; + // List formatos = [".jpg", ".jpeg", ".png", ".gif"]; + // + // String imageUrl = "$baseUrl${user.rut?.rut}${formatos[0]}"; + // + // for (final formato in formatos) { + // String actualImageUrl = "$baseUrl${user.rut?.rut}$formato"; + // final imageResponse = await HttpClient.authClient.head(actualImageUrl); + // + // if (imageResponse.statusCode == 200) { + // imageUrl = actualImageUrl; + // return imageUrl; + // } + // } + // + // return imageUrl; + + throw Exception("Not implemented"); } - static Future> buscarDocentes(String nombre) async { - String uri = "/docentes/buscar"; - - try { - dynamic data = {"nombre": nombre}; - - Response response = await _dio.get(uri, queryParameters: data); - - List usuarios = Usuario.fromJsonList(response.data["docentes"]); - List usuariosConFoto = []; - for (var usuario in usuarios) { - Usuario usuarioConFoto = usuario; - - usuarioConFoto.fotoUrl = await generarImagenPerfil(usuario); - usuariosConFoto.add(usuarioConFoto); - } - return usuariosConFoto; - } on DioError catch (e) { - print(e.message); - throw e; - } + static Future> buscarDocentes(String nombre) async { + // String uri = "/docentes/buscar"; + // + // try { + // dynamic data = {"nombre": nombre}; + // + // Response response = await _dio.get(uri, queryParameters: data); + // + // List usuarios = User.fromJsonList(response.data["docentes"]); + // List usuariosConFoto = []; + // for (var usuario in usuarios) { + // User usuarioConFoto = usuario; + // + // usuarioConFoto.fotoUrl = await generarImagenPerfil(usuario); + // usuariosConFoto.add(usuarioConFoto); + // } + // return usuariosConFoto; + // } on DioError catch (e) { + // print(e.message); + // throw e; + // } + + return []; } - static Future traerUnDocente(String? nombre) async { - String uri = "/docentes/buscar"; - - try { - dynamic data = {"nombre": nombre, "limit": 1}; - - Response response = await _dio.get(uri, queryParameters: data); - - Usuario usuario = Usuario.fromJson(response.data); - - Usuario usuarioConFoto = usuario; - usuarioConFoto.fotoUrl = await generarImagenPerfil(usuario); - - return usuarioConFoto; - } on DioError catch (e) { - print(e.message); - throw e; - } + static Future traerUnDocente(String? nombre) async { + // String uri = "/docentes/buscar"; + // + // try { + // dynamic data = {"nombre": nombre, "limit": 1}; + // + // Response response = await _dio.get(uri, queryParameters: data); + // + // User user = User.fromJson(response.data); + // user.fotoUrl = await generarImagenPerfil(user); + // return user; + // } on DioError catch (e) { + // print(e.message); + // throw e; + // } + + throw Exception("Not implemented"); } - static Future asignarUnDocente(String? nombreDocente, - String? codigoAsignatura, String? nombreAsignatura) async { - String uri = "/docentes/asignar"; - - try { - dynamic data = { - "nombreDocente": nombreDocente, - "codigoAsignatura": codigoAsignatura, - "nombreAsignatura": nombreAsignatura - }; - - Response response = await _dio.post(uri, data: data); - - Usuario usuario = Usuario.fromJson(response.data); - - Usuario usuarioConFoto = usuario; - usuarioConFoto.fotoUrl = await generarImagenPerfil(usuario); - - return usuarioConFoto; - } on DioError catch (e) { - print(e.message); - throw e; - } + static Future asignarUnDocente(String? nombreDocente, String? codigoAsignatura, String? nombreAsignatura) async { + // String uri = "/docentes/asignar"; + // + // try { + // dynamic data = { + // "nombreDocente": nombreDocente, + // "codigoAsignatura": codigoAsignatura, + // "nombreAsignatura": nombreAsignatura + // }; + // + // Response response = await _dio.post(uri, data: data); + // + // User user = User.fromJson(response.data); + // user.fotoUrl = await generarImagenPerfil(user); + // return user; + // } on DioError catch (e) { + // print(e.message); + // throw e; + // } + + throw Exception("Not implemented"); } } diff --git a/lib/services/grades_service.dart b/lib/services/grades_service.dart index 4d13597..a9caea9 100644 --- a/lib/services/grades_service.dart +++ b/lib/services/grades_service.dart @@ -1,40 +1,233 @@ -import 'package:dio/dio.dart'; -import 'package:dio_http_cache/dio_http_cache.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:mi_utem/controllers/grades_changes_controller.dart'; -import 'package:mi_utem/models/grades.dart'; -import 'package:mi_utem/services/perfil_service.dart'; -import 'package:mi_utem/utils/dio_miutem_client.dart'; +import 'dart:convert'; + +import 'package:get/get.dart'; +import 'package:mi_utem/config/secure_storage.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; +import 'package:mi_utem/models/carrera.dart'; +import 'package:mi_utem/models/evaluacion/grades.dart'; +import 'package:mi_utem/repositories/asignaturas_repository.dart'; +import 'package:mi_utem/repositories/grades_repository.dart'; +import 'package:mi_utem/services/auth_service.dart'; +import 'package:mi_utem/services/carreras_service.dart'; +import 'package:mi_utem/services/notification_service.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; class GradesService { - static final Dio _dio = DioMiUtemClient.authDio; - static final GetStorage box = GetStorage(); - - static Future getGrades( - String carreraId, - String asignaturaId, { - bool forceRefresh = false, - bool saveGrades = true, - }) async { - final uri = "/v1/carreras/$carreraId/asignaturas/$asignaturaId/notas"; - final user = PerfilService.getLocalUsuario(); - - Response response = await _dio.get( - uri, - options: buildCacheOptions( - Duration(days: 1), - maxStale: Duration(days: 7), - forceRefresh: forceRefresh, - subKey: user.rut?.numero.toString(), - ), - ); - - Grades grades = Grades.fromJson(response.data); - - if (saveGrades) { - GradesChangesController.saveGrades(asignaturaId, grades); + static const savedGradesPrefix = 'savedGrades_'; + static const subscribedAsignaturasPrefix = 'subscribedAsignaturas_'; + GradesRepository _gradesRepository = Get.find(); + + Future getGrades(String carreraId, String asignaturaId, {bool forceRefresh = false, bool saveGrades = true}) async { + final grades = await _gradesRepository.getGrades(carreraId: carreraId, asignaturaId: asignaturaId); + + if(saveGrades && grades != null) { + await this.saveGrades(asignaturaId, grades); } return grades; } + + Future saveGrades(String asignaturaId, Grades grades) { + final jsonGrades = grades.toJson(); + jsonGrades['lastUpdate'] = DateTime.now().toIso8601String(); + return secureStorage.write(key: '$savedGradesPrefix$asignaturaId', value: jsonEncode(jsonGrades)); + } + + Future> lookForGradeUpdates() async { + final isLoggedIn = await Get.find().isLoggedIn(); + + if(!isLoggedIn) { + return {}; + } + + final carrera = await Get.find().getCarreras(); + final carreraId = carrera?.id; + + if(carrera == null || carreraId == null) { + return {}; + } + + final subscribedAsignaturasJson = await secureStorage.read(key: '$subscribedAsignaturasPrefix$carreraId'); + List subscribedAsignaturas; + if(subscribedAsignaturasJson == null) { + subscribedAsignaturas = (await Get.find().getAsignaturas(carreraId)) ?? []; + await secureStorage.write(key: '$subscribedAsignaturasPrefix$carreraId', value: jsonEncode(subscribedAsignaturas.map((it) => it.toJson()).toList())); + } else { + subscribedAsignaturas = Asignatura.fromJsonList(jsonDecode(subscribedAsignaturasJson) as List); + } + + final response = Map(); + + for(Asignatura? asignatura in subscribedAsignaturas) { + final asignaturaId = asignatura?.id; + if(asignatura == null || asignaturaId == null) { + continue; + } + + final updatedGrades = await this.getGrades(carreraId, asignaturaId, forceRefresh: true, saveGrades: false); + if(updatedGrades == null) { + continue; + } + + final changeType = await this.compareGrades(asignaturaId, updatedGrades); + await this.saveGrades(asignaturaId, updatedGrades); + + this._notifyGradeUpdate(carrera, asignatura, changeType); + + response[asignaturaId] = changeType; + } + + return response; + } + + Future compareGrades(String asignaturaId, Grades grades) async { + final prevGradesJson = await secureStorage.read(key: '$savedGradesPrefix$asignaturaId'); + if(prevGradesJson == null) { + return GradeChangeType.noChange; + } + + final prevGrades = Grades.fromJson(jsonDecode(prevGradesJson) as Map); + final prevGradesLength = prevGrades.notasParciales.length; + final currentGradesLength = grades.notasParciales.length; + + if(prevGradesLength == 0) { + if(currentGradesLength == 0) { + return GradeChangeType.noChange; + } + + if(_hasAGradeWithValue(grades)) { + return GradeChangeType.gradeSet; + } + + return GradeChangeType.weightingsSet; + } + + if(currentGradesLength == 0) { + return GradeChangeType.weightingsDeleted; + } else if(prevGradesLength != currentGradesLength) { + return GradeChangeType.weightingsUpdated; + } else if(_hasAWeightingDifference(prevGrades, grades)) { + return GradeChangeType.weightingsUpdated; + } + + return _getGradeValueChangeType(prevGrades, grades); + } + + GradeChangeType _getGradeValueChangeType(Grades prev, Grades current) { + final prevLength = prev.notasParciales.length; + final currentLength = current.notasParciales.length; + if(prevLength != currentLength) { + Sentry.captureMessage("Asignatura $prev.id tiene un número distinto de ponderadores en la función _getGradeValueChangeType", + level: SentryLevel.warning + ); + return GradeChangeType.noChange; + } + + GradeChangeType? changeType; + + for(int i = 0; i < prevLength; i++) { + final prevValue = prev.notasParciales[i]; + final currentValue = current.notasParciales[i]; + if(prevValue.nota == currentValue.nota) { + continue; + } + + if(prevValue.nota == null && currentValue.nota != null) { + Sentry.configureScope((scope) => scope.setExtra('newGrade', currentValue.nota)); + changeType = GradeChangeType.gradeSet; + } else if(prevValue.nota != null && currentValue.nota == null) { + changeType = changeType ?? GradeChangeType.gradeDeleted; + } else { + changeType = changeType ?? GradeChangeType.gradeUpdated; + } + } + + return changeType ?? GradeChangeType.noChange; + } + + bool _hasAWeightingDifference(Grades pev, Grades current) { + final prevLength = pev.notasParciales.length; + final currentLength = current.notasParciales.length; + if(prevLength != currentLength) { + Sentry.captureMessage("Asignatura $pev.id tiene un número distinto de ponderadores en la función _hasAWeightingDifference", + level: SentryLevel.warning + ); + return false; + } + + for(int i = 0; i < prevLength; i++) { + final prevValue = pev.notasParciales[i]; + final currentValue = current.notasParciales[i]; + if(prevValue.porcentaje != currentValue.porcentaje) { + return true; + } + } + + return false; + } + + bool _hasAGradeWithValue(Grades asignatura) => + asignatura.notasParciales.any((it) => it.nota != null); + + void _notifyGradeUpdate(Carrera carrera, Asignatura asignatura, GradeChangeType changeType) { + final name = asignatura.nombre; + + String? title; + String? body; + + switch(changeType) { + case GradeChangeType.gradeSet: + title = "Tienes una nueva nota"; + body = "$name: se ha agregado una nota."; + break; + case GradeChangeType.gradeUpdated: + title = "Una nota ha cambiado"; + body = "$name: se ha actualizado una nota."; + break; + case GradeChangeType.gradeDeleted: + title = "Una nota se ha borrado"; + body = "$name: se ha eliminado una nota."; + break; + default: + break; + } + + if(title != null && body != null) { + Sentry.captureMessage("Asignatura ha cambiado y notificado", + level: SentryLevel.debug, + withScope: (scope) { + scope.setTag("asignaturaId", asignatura.id.toString()); + scope.setTag("asignaturaCodigo", asignatura.codigo.toString()); + scope.setTag("change", changeType.toString()); + } + ); + + NotificationService.showGradeChangeNotification( + title: title, + body: body, + asignatura: asignatura, + carrera: carrera, + ); + } else if(changeType != GradeChangeType.noChange) { + Sentry.captureMessage("Asignatura ha cambiado pero no notificado", + level: SentryLevel.debug, + withScope: (scope) { + scope.setTag("asignaturaId", asignatura.id.toString()); + scope.setTag("asignaturaCodigo", asignatura.codigo.toString()); + scope.setTag("change", changeType.toString()); + } + ); + } + } + } + +enum GradeChangeType { + weightingsSet, + weightingsUpdated, + weightingsDeleted, + gradeSet, + gradeUpdated, + gradeDeleted, + noChange +} \ No newline at end of file diff --git a/lib/services/horarios_service.dart b/lib/services/horarios_service.dart deleted file mode 100644 index b185f15..0000000 --- a/lib/services/horarios_service.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:dio_http_cache/dio_http_cache.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:mi_utem/models/horario.dart'; -import 'package:mi_utem/services/perfil_service.dart'; -import 'package:mi_utem/utils/dio_miutem_client.dart'; - -class HorarioService { - static final Dio _dio = DioMiUtemClient.authDio; - static final GetStorage box = GetStorage(); - - static Future getHorario( - String carreraId, { - bool forceRefresh = false, - }) async { - final uri = "/v1/carreras/$carreraId/horarios"; - final user = PerfilService.getLocalUsuario(); - - try { - Response response = await _dio.get( - uri, - options: buildCacheOptions( - Duration(days: 30), - forceRefresh: true, - subKey: user.rut?.numero.toString(), - ), - ); - - Horario horario = Horario.fromJson(response.data); - - return horario; - } on DioError catch (e) { - print(e.message); - throw e; - } - } -} diff --git a/lib/services/noticias_service.dart b/lib/services/noticias_service.dart deleted file mode 100644 index 3ae6931..0000000 --- a/lib/services/noticias_service.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:dio/dio.dart'; - -import 'package:mi_utem/models/noticia.dart'; -import 'package:mi_utem/utils/dio_wordpress_client.dart'; - -class NoticiasService { - static final Dio _dio = DioWordpressClient.initDio; - - static Future> getNoticias() async { - String uri = "/posts"; - - try { - Response response = await _dio.get(uri); - - List noticias = Noticia.fromJsonList(response.data); - - return await Future.wait(noticias.map((noticia) async { - String uri = '/media/${noticia.featuredMediaId}'; - try { - if (noticia.featuredMediaId != null && noticia.featuredMediaId != 0) { - Response response = await _dio.get(uri); - noticia.featuredMedia = FeaturedMedia.fromJson(response.data); - } else { - noticia.featuredMedia = FeaturedMedia.empty(); - } - return noticia; - } catch (e) { - print(e); - noticia.featuredMedia = FeaturedMedia.empty(); - return noticia; - } - }).toList()); - } on DioError catch (e) { - print(e.message); - throw e; - } - } -} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 77c40d9..fca5429 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,12 +1,9 @@ -import 'dart:convert'; -import 'dart:math'; - import 'package:awesome_notifications/awesome_notifications.dart'; import 'package:awesome_notifications_fcm/awesome_notifications_fcm.dart'; -import 'package:get/get.dart'; +import 'package:flutter/material.dart'; import 'package:mi_utem/controllers/notification_controller.dart'; -import 'package:mi_utem/models/asignatura.dart'; -import 'package:mi_utem/widgets/custom_alert_dialog.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; +import 'package:mi_utem/models/carrera.dart'; class NotificationService { static const announcementsChannelKey = 'announcements_channel'; @@ -37,8 +34,7 @@ class NotificationService { channelGroupKey: 'grade_changes', channelKey: gradeChangesChannelKey, channelName: 'Grades changes', - channelDescription: - 'Notification channel to notify you when your grades change', + channelDescription: 'Notification channel to notify you when your grades change', channelShowBadge: true, importance: NotificationImportance.High, ), @@ -58,61 +54,64 @@ class NotificationService { notifications.setListeners( onActionReceivedMethod: NotificationController.onActionReceivedMethod, - onNotificationCreatedMethod: - NotificationController.onNotificationCreatedMethod, - onNotificationDisplayedMethod: - NotificationController.onNotificationDisplayedMethod, - onDismissActionReceivedMethod: - NotificationController.onDismissActionReceivedMethod, + onNotificationCreatedMethod: NotificationController.onNotificationCreatedMethod, + onNotificationDisplayedMethod: NotificationController.onNotificationDisplayedMethod, + onDismissActionReceivedMethod: NotificationController.onDismissActionReceivedMethod, ); } - static Future requestUserPermissionIfNecessary() async { - bool isAllowed = await notifications.isNotificationAllowed(); + static Future hasAllowedNotifications() async => await notifications.isNotificationAllowed(); + + static Future requestUserPermissionIfNecessary(BuildContext context) async { + bool isAllowed = await hasAllowedNotifications(); if (!isAllowed) { - isAllowed = await Get.dialog( - CustomAlertDialog( - titulo: "Activa las notificaciones", - emoji: "🔔", - descripcion: - "Necesitamos tu permiso para poder enviarte notificaciones. Nada de spam, lo prometemos.", - onCancelar: () async { - bool isAllowed = await notifications.isNotificationAllowed(); - Get.back(result: isAllowed); - Get.back(result: isAllowed); - }, - onConfirmar: () async { - await notifications.requestPermissionToSendNotifications(); - bool isAllowed = await notifications.isNotificationAllowed(); - Get.back(result: isAllowed); - Get.back(result: isAllowed); - }, - cancelarTextoBoton: "No permitir", - confirmarTextoBoton: "Permitir", - ), - ); + await notifications.requestPermissionToSendNotifications(); + isAllowed = await notifications.isNotificationAllowed(); } return isAllowed; } - static void showGradeChangeNotification( - String title, - String body, - Asignatura asignatura, - ) { - final Map payload = { - 'type': 'grade_change', - 'asignatura': jsonEncode(asignatura.toJson()), - }; + static void showAnnouncementNotification({ + required String title, + required String body, + Map payload = const {}, + }) async { + if(!await hasAllowedNotifications()) { + return; + } - notifications.createNotification( - content: NotificationContent( - id: Random().nextInt(1000000), - channelKey: gradeChangesChannelKey, - title: title, - body: body, - payload: payload, - ), - ); + notifications.createNotification(content: NotificationContent( + id: payload.hashCode, + channelKey: announcementsChannelKey, + title: title, + body: body, + payload: { + 'type': 'announcement', + ...payload, + }, + )); + } + + static void showGradeChangeNotification({ + required String title, + required String body, + required Carrera carrera, + required Asignatura asignatura, + }) async { + if(!await hasAllowedNotifications()) { + return; + } + + notifications.createNotification(content: NotificationContent( + id: asignatura.hashCode, + channelKey: gradeChangesChannelKey, + title: title, + body: body, + payload: { + 'type': 'grade_change', + 'asignatura': asignatura.toString(), + 'carrera': carrera.toString(), + }, + )); } } diff --git a/lib/services/perfil_service.dart b/lib/services/perfil_service.dart deleted file mode 100644 index 7adfa25..0000000 --- a/lib/services/perfil_service.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:dio/dio.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:mi_utem/models/rut.dart'; -import 'package:mi_utem/models/usuario.dart'; -import 'package:mi_utem/services/notification_service.dart'; -import 'package:mi_utem/utils/dio_miutem_client.dart'; - -class PerfilService { - static final Dio _dio = DioMiUtemClient.authDio; - static final GetStorage box = GetStorage(); - - static Usuario getLocalUsuario() { - try { - String? token = box.read("token"); - String? nombres = box.read("nombres"); - String? apellidos = box.read("apellidos"); - String? nombre = box.read("nombre"); - String? fotoUrl = box.read("fotoUrl"); - Rut? rut = box.read("rut") != null - ? (box.read("rut") is int - ? Rut.deEntero(box.read("rut")) - : Rut.deString(box.read("rut"))) - : null; - String? correoUtem = box.read("correoUtem"); - String? correoPersonal = box.read("correoPersonal"); - - return Usuario( - token: token, - nombres: nombres, - fotoUrl: fotoUrl, - nombre: nombre, - apellidos: apellidos, - rut: rut, - correoUtem: correoUtem, - correoPersonal: correoPersonal, - ); - } catch (e) { - print(e); - throw e; - } - } - - static Future changeFoto(String imagen) async { - String uri = "/v1/usuarios/foto"; - - try { - dynamic data = {'imagen': imagen}; - - Response response = await _dio.put(uri, data: data); - - String fotoUrl = response.data["fotoUrl"]; - - box.write('fotoUrl', fotoUrl); - - Usuario usuario = getLocalUsuario(); - - return usuario; - } on DioError catch (e) { - print(e.message); - throw e; - } - } - - static Future deleteFcmToken() async { - String? fcmToken = await NotificationService.fcm.requestFirebaseAppToken(); - CollectionReference usuariosCollection = - FirebaseFirestore.instance.collection('usuarios'); - - QuerySnapshot snapshotRepetidas = await usuariosCollection - .where("fcmTokens", arrayContains: fcmToken) - .get(); - - for (var doc in snapshotRepetidas.docs) { - print("uno repetido ${doc.id}"); - doc.reference.set( - { - "fcmTokens": FieldValue.arrayRemove([fcmToken]), - }, - SetOptions( - merge: true, - ), - ); - } - } - - static Future saveFcmToken() async { - try { - String? fcmToken = - await NotificationService.fcm.requestFirebaseAppToken(); - Usuario usuario = PerfilService.getLocalUsuario(); - CollectionReference usuariosCollection = - FirebaseFirestore.instance.collection('usuarios'); - - await PerfilService.deleteFcmToken(); - - usuariosCollection.doc(usuario.rut!.numero.toString()).set( - { - "fcmTokens": FieldValue.arrayUnion([fcmToken]), - }, - SetOptions( - merge: true, - ), - ); - } catch (e) { - print(e); - } - } -} diff --git a/lib/services/permisos_covid_service.dart b/lib/services/permisos_covid_service.dart deleted file mode 100644 index 7d6e89f..0000000 --- a/lib/services/permisos_covid_service.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:dio_http_cache/dio_http_cache.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:mi_utem/config/logger.dart'; -import 'package:mi_utem/models/permiso_covid.dart'; -import 'package:mi_utem/services/perfil_service.dart'; -import 'package:mi_utem/utils/dio_miutem_client.dart'; - -class PermisosCovidService { - static final Dio _dio = DioMiUtemClient.authDio; - static final GetStorage box = GetStorage(); - - static Future> getPermisos( - {bool forceRefresh = false}) async { - const uri = "/v1/permisos"; - - final user = PerfilService.getLocalUsuario(); - - logger.d("Obteniendo permisos de ${user.rut?.numero}"); - - try { - Response response = await _dio.post( - uri, - options: buildCacheOptions( - Duration(days: 300), - maxStale: Duration(days: 300), - forceRefresh: forceRefresh, - subKey: user.rut?.numero.toString(), - ), - ); - - return PermisoCovid.fromJsonList(response.data); - } on DioError catch (e) { - print(e.message); - throw e; - } - } - - static Future getDetallesPermiso( - String id, { - bool forceRefresh = false, - }) async { - final uri = "/v1/permisos/$id"; - - final user = PerfilService.getLocalUsuario(); - - try { - Response response = await _dio.post( - uri, - options: buildCacheOptions( - Duration(days: 180), - maxStale: Duration(days: 365), - forceRefresh: forceRefresh, - subKey: user.rut?.numero.toString(), - ), - ); - - return PermisoCovid.fromJson(response.data); - } on DioError catch (e) { - print(e.message); - throw e; - } - } -} diff --git a/lib/services/remote_config/defaults.dart b/lib/services/remote_config/defaults.dart index eaba945..2c331b7 100644 --- a/lib/services/remote_config/defaults.dart +++ b/lib/services/remote_config/defaults.dart @@ -2,13 +2,10 @@ part of 'remote_config.dart'; final _defaults = { RemoteConfigServiceKeys.banners: jsonEncode([]), - RemoteConfigServiceKeys.creditos: jsonEncode( - ['Hecho con ❤ por el *Club de Desarrollo Experimental* junto a SISEI']), + RemoteConfigServiceKeys.creditos: jsonEncode(['Hecho con ❤ por el *Club de Desarrollo Experimental* junto a SISEI']), RemoteConfigServiceKeys.clubNombre: "Club de Desarrollo Experimental", - RemoteConfigServiceKeys.clubDescripcion: - "El Club de Desarrollo Experimental es una iniciativa de estudiantes y para estudiantes de la UTEM que busca realzar el lado tecnológico que debería tener la universidad, impulsando y desarrollando ideas y proyectos de caracter innovador.", - RemoteConfigServiceKeys.clubLogo: - "https://user-images.githubusercontent.com/16374322/114324335-737b6b80-9af7-11eb-841d-9d14aca0f988.png", + RemoteConfigServiceKeys.clubDescripcion: "El Club de Desarrollo Experimental es una iniciativa de estudiantes y para estudiantes de la UTEM que busca realzar el lado tecnológico que debería tener la universidad, impulsando y desarrollando ideas y proyectos de caracter innovador.", + RemoteConfigServiceKeys.clubLogo: "https://user-images.githubusercontent.com/16374322/114324335-737b6b80-9af7-11eb-841d-9d14aca0f988.png", RemoteConfigServiceKeys.clubRedes: jsonEncode([ { "nombre": "Facebook", @@ -35,10 +32,9 @@ final _defaults = { "url": "https://www.linkedin.com/company/exdevutem/" } ]), - RemoteConfigServiceKeys.miutemDescripcion: - "Esta aplicación surgió a principios del 2019 como un proyecto independiente **creado completamente por estudiantes** del Club de Desarrollo Experimental (ExDev) de la UTEM ❤️. \nActualmente nos encontramos trabajando **junto al equipo de SISEI** para que esta aplicación se convierta en la aplicación institucional oficial de la universidad 🎉 \nToda la información corresponde a datos referenciales, y debe ser validada por la Dirección General de Docencia.", - RemoteConfigServiceKeys.miutemPortada: - "https://user-images.githubusercontent.com/16374322/114324046-16cb8100-9af6-11eb-9a95-11da425e2fbd.png", + RemoteConfigServiceKeys.miutemAcercaDeLaApp: "- Paquete: **%paquete**\n- Aplicación: **%nombre**\n- Versión: **%version**\n- Número de Compilación: **%compilacion**", + RemoteConfigServiceKeys.miutemDescripcion: "Esta aplicación surgió a principios del 2019 como un proyecto independiente **creado completamente por estudiantes** del Club de Desarrollo Experimental (ExDev) de la UTEM ❤️. \nActualmente nos encontramos trabajando **junto al equipo de SISEI** para que esta aplicación se convierta en la aplicación institucional oficial de la universidad 🎉 \nToda la información corresponde a datos referenciales, y debe ser validada por la Dirección General de Docencia.", + RemoteConfigServiceKeys.miutemPortada: "https://user-images.githubusercontent.com/16374322/114324046-16cb8100-9af6-11eb-9a95-11da425e2fbd.png", RemoteConfigServiceKeys.miutemDesarrolladores: jsonEncode([ { "nombre": "Sebastián Albornoz Medina", @@ -121,6 +117,25 @@ final _defaults = { } ] }, + { + "nombre": "Francisco Solís Maturana", + "rol": "Desarrollador", + "fotoUrl": "https://avatars.githubusercontent.com/u/30329003", + "redes": [ + { + "nombre": "LinkedIn", + "color": Color(0xFF0077b5).value, + "icono": FontAwesomeIcons.linkedinIn.codePoint, + "url": "https://www.linkedin.com/in/franciscosolismat/" + }, + { + "nombre": "GitHub", + "color": Color(0xFF333333).value, + "icono": FontAwesomeIcons.github.codePoint, + "url": "https://github.com/Im-Fran", + } + ] + }, { "nombre": "Jorge Verdugo Chacón", "rol": "Desarrollador", @@ -168,19 +183,14 @@ final _defaults = { ] } ]), - RemoteConfigServiceKeys.credencialBarras: - "Código de barras compatible con Sistema de Bibliotecas", - RemoteConfigServiceKeys.credencialInfo: - "**Mayor información:** \n\n [biblioteca.utem.cl](https://biblioteca.utem.cl/)\n\nEn caso de dudas con su credencial, consultar a su biblioteca o al correo electrónico [**credenciales@utem.cl**](mailto:credenciales@utem.cl)", - RemoteConfigServiceKeys.credencialDisclaimer: - "**Esta credencial virtual es generada automáticamente y es de uso personal e intransferible. El atraso en la devolución de libros y revistas será sancionado por la biblioteca.**", - RemoteConfigServiceKeys.credencialSibutemLogo: - "https://user-images.githubusercontent.com/16374322/114325090-42ea0080-9afc-11eb-9cc8-ef4846d4ad8f.jpg", + RemoteConfigServiceKeys.credencialBarras: "Código de barras compatible con Sistema de Bibliotecas", + RemoteConfigServiceKeys.credencialInfo: "**Mayor información:** \n\n [biblioteca.utem.cl](https://biblioteca.utem.cl/)\n\nEn caso de dudas con su credencial, consultar a su biblioteca o al correo electrónico [**credenciales@utem.cl**](mailto:credenciales@utem.cl)", + RemoteConfigServiceKeys.credencialDisclaimer: "**Esta credencial virtual es generada automáticamente y es de uso personal e intransferible. El atraso en la devolución de libros y revistas será sancionado por la biblioteca.**", + RemoteConfigServiceKeys.credencialSibutemLogo: "https://user-images.githubusercontent.com/16374322/114325090-42ea0080-9afc-11eb-9cc8-ef4846d4ad8f.jpg", RemoteConfigServiceKeys.calculadoraMostrar: true, RemoteConfigServiceKeys.horarioZoom: 0.5, RemoteConfigServiceKeys.homeProntoIcono: Icons.pregnant_woman.codePoint, - RemoteConfigServiceKeys.homeProntoTexto: - "Se están gestando nuevas funciones 😎", + RemoteConfigServiceKeys.homeProntoTexto: "Se están gestando nuevas funciones 😎", RemoteConfigServiceKeys.prontoEg: "Este recurso no esta disponible", RemoteConfigServiceKeys.egHabilitados: true, RemoteConfigServiceKeys.drawerMenu: jsonEncode([ diff --git a/lib/services/remote_config/keys.dart b/lib/services/remote_config/keys.dart index f6fae07..d8e1652 100644 --- a/lib/services/remote_config/keys.dart +++ b/lib/services/remote_config/keys.dart @@ -5,14 +5,14 @@ class RemoteConfigServiceKeys { static const String clubNombre = 'club_nombre'; static const String clubDescripcion = 'club_descripcion'; static const String clubLogo = 'club_logo_url'; + static const String miutemAcercaDeLaApp = 'miutem_acerca_de_la_app'; static const String miutemDescripcion = 'miutem_descripcion'; static const String miutemDesarrolladores = 'miutem_desarrolladores'; static const String miutemPortada = 'miutem_portada_url'; static const String credencialBarras = 'miutem_credencial_barras_detalle'; static const String credencialDisclaimer = 'miutem_credencial_disclaimer'; static const String credencialInfo = 'miutem_credencial_info'; - static const String credencialSibutemLogo = - 'miutem_credencial_sibutem_logo_url'; + static const String credencialSibutemLogo = 'miutem_credencial_sibutem_logo_url'; static const String calculadoraMostrar = 'miutem_calculadora_mostrar'; static const String horarioZoom = 'miutem_horario_zoom'; static const String homeProntoIcono = 'miutem_home_pronto_icono'; diff --git a/lib/services/remote_config/remote_config.dart b/lib/services/remote_config/remote_config.dart index c288086..48580a7 100644 --- a/lib/services/remote_config/remote_config.dart +++ b/lib/services/remote_config/remote_config.dart @@ -4,8 +4,9 @@ import 'package:firebase_remote_config/firebase_remote_config.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:mdi/mdi.dart'; +import 'package:mi_utem/config/logger.dart'; +import 'package:mi_utem/models/novedades/ibanner.dart'; import 'package:mi_utem/services/remote_config/keys.dart'; -import 'package:mi_utem/widgets/banner.dart'; part 'defaults.dart'; @@ -16,48 +17,34 @@ class RemoteConfigService { static final defaults = _defaults; - static String _getString(String key) => - _firebaseRemoteConfigInstance.getString(key); - static bool _getBool(String key) => - _firebaseRemoteConfigInstance.getBool(key); - static double _getDouble(String key) => - _firebaseRemoteConfigInstance.getDouble(key); + static String _getString(String key) => _firebaseRemoteConfigInstance.getString(key); + static bool _getBool(String key) => _firebaseRemoteConfigInstance.getBool(key); + static double _getDouble(String key) => _firebaseRemoteConfigInstance.getDouble(key); static int _getInt(String key) => _firebaseRemoteConfigInstance.getInt(key); - static final banners = IBanner.fromJsonList( - jsonDecode(_getString(RemoteConfigServiceKeys.banners))); + static final banners = IBanner.fromJsonList(jsonDecode(_getString(RemoteConfigServiceKeys.banners))); static final creditos = _getString(RemoteConfigServiceKeys.creditos); static final clubRedes = _getString(RemoteConfigServiceKeys.clubRedes); static final clubNombre = _getString(RemoteConfigServiceKeys.clubNombre); - static final clubDescripcion = - _getString(RemoteConfigServiceKeys.clubDescripcion); + static final clubDescripcion = _getString(RemoteConfigServiceKeys.clubDescripcion); static final clubLogo = _getString(RemoteConfigServiceKeys.clubLogo); - static final miutemDescripcion = - _getString(RemoteConfigServiceKeys.miutemDescripcion); - static final miutemDesarrolladores = - _getString(RemoteConfigServiceKeys.miutemDesarrolladores); - static final miutemPortada = - _getString(RemoteConfigServiceKeys.miutemPortada); - static final credencialBarras = - _getString(RemoteConfigServiceKeys.credencialBarras); - static final credencialDisclaimer = - _getString(RemoteConfigServiceKeys.credencialDisclaimer); - static final credencialInfo = - _getString(RemoteConfigServiceKeys.credencialInfo); - static final credencialSibutemLogo = - _getString(RemoteConfigServiceKeys.credencialSibutemLogo); - static final calculadoraMostrar = - _getBool(RemoteConfigServiceKeys.calculadoraMostrar); + static final miutemAcercaDeLaApp = _getString(RemoteConfigServiceKeys.miutemAcercaDeLaApp); + static final miutemDescripcion = _getString(RemoteConfigServiceKeys.miutemDescripcion); + static final miutemDesarrolladores = _getString(RemoteConfigServiceKeys.miutemDesarrolladores); + static final miutemPortada = _getString(RemoteConfigServiceKeys.miutemPortada); + static final credencialBarras = _getString(RemoteConfigServiceKeys.credencialBarras); + static final credencialDisclaimer = _getString(RemoteConfigServiceKeys.credencialDisclaimer); + static final credencialInfo = _getString(RemoteConfigServiceKeys.credencialInfo); + static final credencialSibutemLogo = _getString(RemoteConfigServiceKeys.credencialSibutemLogo); + static final calculadoraMostrar = _getBool(RemoteConfigServiceKeys.calculadoraMostrar); static final horarioZoom = _getDouble(RemoteConfigServiceKeys.horarioZoom); - static final homeProntoIcono = - _getInt(RemoteConfigServiceKeys.homeProntoIcono); - static final homeProntoTexto = - _getString(RemoteConfigServiceKeys.homeProntoTexto); + static final homeProntoIcono = _getInt(RemoteConfigServiceKeys.homeProntoIcono); + static final homeProntoTexto = _getString(RemoteConfigServiceKeys.homeProntoTexto); static final prontoEg = _getString(RemoteConfigServiceKeys.prontoEg); static final egHabilitados = _getBool(RemoteConfigServiceKeys.egHabilitados); static final drawerMenu = _getString(RemoteConfigServiceKeys.drawerMenu); static final greetings = _getString(RemoteConfigServiceKeys.greetings); - static final quickMenu = _getString(RemoteConfigServiceKeys.quickMenu); + static final quickMenu = (jsonDecode(_getString(RemoteConfigServiceKeys.quickMenu)) as List).map((e) => e as Map).toList(); factory RemoteConfigService() => instance; @@ -67,30 +54,28 @@ class RemoteConfigService { try { await _firebaseRemoteConfigInstance.setDefaults(defaults); await _firebaseRemoteConfigInstance.fetchAndActivate(); + await _firebaseRemoteConfigInstance.setConfigSettings(RemoteConfigSettings( + minimumFetchInterval: Duration(hours: 12), + fetchTimeout: Duration(minutes: 1), + )); } catch (exception) { - print( - 'Unable to fetch remote config. Cached or default values will be used'); + logger.e('Error al descargar la configuración remota. Se usarán los valores guardados en cache o los valores por defecto', exception); } } static Future update() async { try { - await _firebaseRemoteConfigInstance.setConfigSettings( - RemoteConfigSettings( - minimumFetchInterval: Duration.zero, - fetchTimeout: Duration(minutes: 1), - ), - ); + await _firebaseRemoteConfigInstance.setConfigSettings(RemoteConfigSettings( + minimumFetchInterval: Duration.zero, + fetchTimeout: Duration(minutes: 1), + )); await _firebaseRemoteConfigInstance.fetchAndActivate(); - await _firebaseRemoteConfigInstance.setConfigSettings( - RemoteConfigSettings( - minimumFetchInterval: Duration(hours: 12), - fetchTimeout: Duration(minutes: 1), - ), - ); + await _firebaseRemoteConfigInstance.setConfigSettings(RemoteConfigSettings( + minimumFetchInterval: Duration(hours: 12), + fetchTimeout: Duration(minutes: 1), + )); } catch (exception) { - print( - 'Unable to fetch remote config. Cached or default values will be used'); + logger.e('Error al descargar la configuración remota. Se usarán los valores guardados en cache o los valores por defecto', exception); } } } diff --git a/lib/services/review_service.dart b/lib/services/review_service.dart index a2cf404..1cd0750 100644 --- a/lib/services/review_service.dart +++ b/lib/services/review_service.dart @@ -1,8 +1,8 @@ -import 'package:get/get.dart'; +import 'package:flutter/material.dart'; import 'package:get_storage/get_storage.dart'; import 'package:in_app_review/in_app_review.dart'; - -import 'package:mi_utem/widgets/custom_alert_dialog.dart'; +import 'package:mi_utem/config/logger.dart'; +import 'package:mi_utem/widgets/dialogs/custom_alert_dialog.dart'; class ReviewService { static final GetStorage box = GetStorage(); @@ -28,12 +28,11 @@ class ReviewService { box.write("review$screenName", DateTime.now().toIso8601String()); return true; } else { - print( - "ERROR ReviewService addScreen: no estaba contemplada esta pantalla"); + logger.e("ERROR ReviewService addScreen: no estaba contemplada esta pantalla"); return false; } } catch (e) { - print(e); + logger.e(e); throw e; } } @@ -47,12 +46,11 @@ class ReviewService { DateTime maxDate = DateTime.now().subtract(maxScreen); DateTime minDate = DateTime.now().subtract(minScreen); if (date.isBefore(maxDate) || date.isAfter(minDate)) { - print( - "ReviewService mustRequest: $screen no cumple con una fecha $isoDate"); + logger.i("ReviewService mustRequest: $screen no cumple con una fecha $isoDate"); return false; } } else { - print("ReviewService mustRequest: $screen no se ha visitado"); + logger.i("ReviewService mustRequest: $screen no se ha visitado"); return false; } } @@ -61,8 +59,7 @@ class ReviewService { DateTime lastRequestDate = DateTime.parse(lastRequestIsoDate); DateTime minDate = DateTime.now().subtract(minRequest); if (lastRequestDate.isAfter(minDate)) { - print( - "ReviewService mustRequest: no ha pasado el minimo desde la ultima request $lastRequestIsoDate"); + logger.i("ReviewService mustRequest: no ha pasado el minimo desde la ultima request $lastRequestIsoDate"); return false; } else { return true; @@ -76,40 +73,35 @@ class ReviewService { } } - static Future checkAndRequestReview() async { + static Future checkAndRequestReview(BuildContext context) async { try { - print("ReviewService checkAndRequestReview"); + logger.i("ReviewService checkAndRequestReview"); bool mustRequest = await ReviewService.mustRequest(); if (mustRequest) { if (await inAppReview.isAvailable()) { - await Get.dialog( - CustomAlertDialog( - titulo: "¿Te gustaría calificar a Mi UTEM?", - emoji: "⭐", - descripcion: - "Te invitamos a dejarnos tus comentarios y estrellitas", - onCancelar: () async { - Get.back(); - }, - onConfirmar: () async { - await inAppReview.requestReview(); - box.write( - "reviewLastRequest", DateTime.now().toIso8601String()); - Get.back(); - }, - cancelarTextoBoton: "No, gracias 😡", - confirmarTextoBoton: "Dejar estrellitas 😊", - ), - ); + await showDialog(context: context, builder: (ctx) => CustomAlertDialog( + titulo: "¿Te gustaría calificar a Mi UTEM?", + emoji: "⭐", + descripcion: "Te invitamos a dejarnos tus comentarios y estrellitas", + onCancelar: () async { + Navigator.pop(ctx); + }, + onConfirmar: () async { + await inAppReview.requestReview(); + box.write("reviewLastRequest", DateTime.now().toIso8601String()); + Navigator.pop(ctx); + }, + cancelarTextoBoton: "No, gracias 😡", + confirmarTextoBoton: "Dejar estrellitas 😊", + )); } else { - print( - "ReviewService checkAndRequestReview: inAppReview no está disponible"); + logger.i("ReviewService checkAndRequestReview: inAppReview no está disponible"); } } else { - print("ReviewService checkAndRequestReview: mustRequest es false"); + logger.i("ReviewService checkAndRequestReview: mustRequest es false"); } } catch (e) { - print(e); + logger.e(e); throw e; } } diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart new file mode 100644 index 0000000..85c8a15 --- /dev/null +++ b/lib/services/update_service.dart @@ -0,0 +1,57 @@ +import 'package:flutter/scheduler.dart'; + +/* + * Clase que se encarga de verificar si hay una nueva versión de la aplicación + * y de actualizarla si es necesario. + * + * Para iOS muestra una notificación de que hay una nueva versión disponible. + */ +class UpdateService { + + UpdateService(){ + SchedulerBinding.instance.addPostFrameCallback((_) => _checkAndPerformUpdate()); + } + + /* try { + VersionStatus status = + await NewVersion(context: context).getVersionStatus(); + print("status.localVersion ${status.localVersion}"); + print("status.storeVersion ${status.storeVersion}"); + + var localVersion = status.localVersion.split("."); + var storeVersion = status.storeVersion.split("."); + if (storeVersion[0].compareTo(localVersion[0]) > 0) { + if (Platform.isAndroid) { + AppUpdateInfo info = await InAppUpdate.checkForUpdate(); + + if (info.updateAvailable == true) { + await InAppUpdate.performImmediateUpdate(); + } + } + } else if (storeVersion[1].compareTo(localVersion[1]) > 0) { + if (Platform.isAndroid) { + AppUpdateInfo info = await InAppUpdate.checkForUpdate(); + + if (info.updateAvailable == true) { + await InAppUpdate.startFlexibleUpdate(); + await InAppUpdate.completeFlexibleUpdate(); + } + } + } else if (storeVersion[2].compareTo(localVersion[2]) > 0) { + print("MINOR"); + } + + return; + } catch (error) { + print("_checkAndPerformUpdate Error: ${error.toString()}"); + } */ + + Future _checkAndPerformUpdate() async { + // if (Platform.isAndroid) { + // final AppUpdateInfo appUpdateInfo = await InAppUpdate.checkForUpdate(); + // if (appUpdateInfo.immediateUpdateAllowed) { + // await InAppUpdate.performImmediateUpdate(); + // } + // } + } +} \ No newline at end of file diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index a44d01d..45a1f8c 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -24,118 +24,92 @@ class MainTheme { static double elevation = 0; static ThemeData get theme => ThemeData( - floatingActionButtonTheme: FloatingActionButtonThemeData( - backgroundColor: primaryColor, - disabledElevation: 0, - elevation: 0, - ), - tabBarTheme: TabBarTheme(indicatorSize: TabBarIndicatorSize.tab), - disabledColor: disabledColor, - textButtonTheme: TextButtonThemeData( - style: ButtonStyle( - elevation: MaterialStateProperty.all(0), - backgroundColor: MaterialStateProperty.resolveWith( - (states) { - const Set interactiveStates = { - MaterialState.disabled, - }; - return states.any(interactiveStates.contains) - ? disabledColor - : primaryColor; - }, - ), - padding: MaterialStateProperty.all( - EdgeInsets.symmetric(vertical: 5, horizontal: 20), - ), - foregroundColor: MaterialStateProperty.all(Colors.white), - shape: MaterialStateProperty.all( - StadiumBorder(), - ), - ), - ), - outlinedButtonTheme: OutlinedButtonThemeData( - style: ButtonStyle( - elevation: MaterialStateProperty.all(0), - side: MaterialStateProperty.resolveWith( - (states) { - const Set interactiveStates = { - MaterialState.disabled, - }; - return states.any(interactiveStates.contains) - ? BorderSide(color: disabledColor!) - : BorderSide(color: primaryColor); - }, - ), - foregroundColor: MaterialStateProperty.resolveWith( - (states) { - const Set interactiveStates = { - MaterialState.disabled, - }; - return states.any(interactiveStates.contains) - ? disabledColor - : primaryColor; - }, - ), - padding: MaterialStateProperty.all( - EdgeInsets.symmetric(vertical: 5, horizontal: 20)), - shape: MaterialStateProperty.all( - StadiumBorder( - side: BorderSide(width: 3), - ), - ), + useMaterial3: false, // Cuando podamos hay que migrar a material 3. + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: primaryColor, + disabledElevation: 0, + elevation: 0, + ), + tabBarTheme: TabBarTheme(indicatorSize: TabBarIndicatorSize.tab), + disabledColor: disabledColor, + textButtonTheme: TextButtonThemeData( + style: ButtonStyle( + elevation: MaterialStateProperty.all(0), + backgroundColor: MaterialStateProperty.resolveWith((states) => states.any({MaterialState.disabled}.contains) ? disabledColor : primaryColor), + padding: MaterialStateProperty.all( + EdgeInsets.symmetric(vertical: 5, horizontal: 20), + ), + foregroundColor: MaterialStateProperty.all(Colors.white), + shape: MaterialStateProperty.all( + StadiumBorder(), + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: ButtonStyle( + elevation: MaterialStateProperty.all(0), + side: MaterialStateProperty.resolveWith((states) => states.any({MaterialState.disabled}.contains) ? BorderSide(color: disabledColor!) : BorderSide(color: primaryColor)), + foregroundColor: MaterialStateProperty.resolveWith((states) => states.any({MaterialState.disabled}.contains) ? disabledColor : primaryColor), + padding: MaterialStateProperty.all(EdgeInsets.symmetric(vertical: 5, horizontal: 20)), + shape: MaterialStateProperty.all( + StadiumBorder( + side: BorderSide(width: 3), ), ), - buttonTheme: ButtonThemeData( - shape: const StadiumBorder(), - padding: EdgeInsets.symmetric(vertical: 5, horizontal: 20), - textTheme: ButtonTextTheme.normal, - disabledColor: disabledColor, - ), - cardTheme: CardTheme( - elevation: 0, - clipBehavior: Clip.antiAlias, - color: Colors.white, - margin: EdgeInsets.all(10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15.0), - side: BorderSide( - color: Colors.grey, - width: 0.3, - style: BorderStyle.solid, - )), - ), - textTheme: textTheme, - appBarTheme: AppBarTheme( - color: primaryColor, - elevation: 0, - iconTheme: IconThemeData(color: Colors.white), - systemOverlayStyle: SystemUiOverlayStyle.light, - ), - inputDecorationTheme: InputDecorationTheme( - contentPadding: EdgeInsets.symmetric(vertical: 10, horizontal: 10), - isDense: true, - hintStyle: TextStyle(color: Colors.grey[400]), - border: OutlineInputBorder( - borderRadius: BorderRadius.all( - Radius.circular(15), - ), - ), - ), - primaryColor: primaryColor, - colorScheme: ColorScheme( - brightness: Brightness.light, - primary: primaryColor, - onPrimary: Colors.white, - secondary: primaryLightColor, - onSecondary: primaryDarkColor, - error: reprobadoColor, - onError: Colors.white, - background: lightGrey, - onBackground: darkGrey, - surface: lightGrey, - onSurface: darkGrey, - ), - ); + ), + ), + buttonTheme: ButtonThemeData( + shape: const StadiumBorder(), + padding: EdgeInsets.symmetric(vertical: 5, horizontal: 20), + textTheme: ButtonTextTheme.normal, + disabledColor: disabledColor, + ), + cardTheme: CardTheme( + elevation: 0, + clipBehavior: Clip.antiAlias, + color: Colors.white, + margin: EdgeInsets.all(10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + side: BorderSide( + color: Colors.grey, + width: 0.3, + style: BorderStyle.solid, + ), + ), + ), + textTheme: textTheme, + appBarTheme: AppBarTheme( + color: primaryColor, + elevation: 0, + iconTheme: IconThemeData(color: Colors.white), + systemOverlayStyle: SystemUiOverlayStyle.light, + ), + inputDecorationTheme: InputDecorationTheme( + contentPadding: EdgeInsets.symmetric(vertical: 10, horizontal: 10), + isDense: true, + hintStyle: TextStyle(color: Colors.grey[400]), + border: OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(15), + ), + ), + ), + primaryColor: primaryColor, + colorScheme: ColorScheme( + brightness: Brightness.light, + primary: primaryColor, + onPrimary: Colors.white, + secondary: primaryLightColor, + onSecondary: primaryDarkColor, + error: reprobadoColor, + onError: Colors.white, + background: lightGrey, + onBackground: darkGrey, + surface: lightGrey, + onSurface: darkGrey, + ), + ); static TextTheme get textTheme => TextTheme( displayLarge: TextStyle( diff --git a/lib/utils/dio_docente_client.dart b/lib/utils/dio_docente_client.dart index 62df54c..a6f2b74 100644 --- a/lib/utils/dio_docente_client.dart +++ b/lib/utils/dio_docente_client.dart @@ -1,6 +1,3 @@ -import 'package:dio/dio.dart'; -import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; -import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; class DioDocenteClient { static const bool isProduction = bool.fromEnvironment('dart.vm.product'); @@ -9,19 +6,4 @@ class DioDocenteClient { static const String productionUrl = 'http://docentes.inndev.studio/v1'; static const String url = isProduction ? productionUrl : productionUrl; - - static Dio _dio = Dio(BaseOptions( - baseUrl: url, - )); - - static CacheOptions get cacheOptions => CacheOptions( - store: HiveCacheStore('docentesutem'), - policy: CachePolicy.forceCache, - maxStale: const Duration(days: 7), - ); - - static Dio get initDio => _dio - ..interceptors.add( - DioCacheInterceptor(options: cacheOptions), - ); } diff --git a/lib/utils/dio_miutem_client.dart b/lib/utils/dio_miutem_client.dart deleted file mode 100644 index 0db10d8..0000000 --- a/lib/utils/dio_miutem_client.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:developer'; - -import 'package:dio/dio.dart'; -import 'package:dio_http_cache/dio_http_cache.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:mi_utem/services/auth_service.dart'; - -class DioMiUtemClient { - static const bool isProduction = bool.fromEnvironment('dart.vm.product'); - static String debugUrl = - dotenv.env['MI_UTEM_API_DEBUG'] ?? 'https://api.exdev.cl/'; - static const String productionUrl = 'https://api.exdev.cl/'; - - static String url = isProduction ? productionUrl : debugUrl; - - static Dio get initDio => Dio(BaseOptions(baseUrl: url)); - - static CacheConfig cacheConfig = CacheConfig( - baseUrl: url, - defaultMaxAge: Duration(days: 7), - defaultMaxStale: Duration(days: 60), - ); - static DioCacheManager dioCacheManager = DioCacheManager(cacheConfig); - - static Dio baseDio = Dio(BaseOptions(baseUrl: url)) - ..interceptors.add(dioCacheManager.interceptor); - - static Dio get authDio => initDio - ..interceptors.add( - AuthInterceptor(), - ); -} - -class AuthInterceptor extends QueuedInterceptor { - AuthInterceptor({ - this.retries = 3, - }); - - /// The number of retries in case of 401 - final int retries; - - @override - Future onRequest( - final RequestOptions options, - final RequestInterceptorHandler handler, - ) async { - try { - final token = AuthService.getToken(); - - options._setAuthenticationHeader(token); - - return handler.next(options); - } catch (e) { - final error = DioError(requestOptions: options, error: e); - handler.reject(error); - } - } - - @override - Future onError( - final DioError err, final ErrorInterceptorHandler handler) async { - final options = err.requestOptions; - - if (err.response?.statusCode != 401) { - return super.onError(err, handler); - } - - final attempt = err.requestOptions._retryAttempt + 1; - if (attempt > retries) { - await _onErrorRefreshingToken(); - return super.onError(err, handler); - } - err.requestOptions._retryAttempt = attempt; - await Future.delayed(const Duration(seconds: 1)); - - // Force refresh auth token - try { - final token = await AuthService.refreshToken(); - - log("Refreshing token, attempt $attempt..."); - - options._setAuthenticationHeader(token); - final response = await DioMiUtemClient.baseDio.fetch(options); - return handler.resolve(response); - } on DioError catch (e) { - super.onError(e, handler); - } catch (e) { - super.onError( - DioError(requestOptions: options, error: e), - handler, - ); - } - } - - Future _onErrorRefreshingToken() async { - AuthService.invalidateToken(); - } -} - -extension AuthRequestOptionsX on RequestOptions { - void _setAuthenticationHeader(final String token) => - headers['Authorization'] = 'Bearer $token'; - - int get _retryAttempt => (extra['auth_retry_attempt'] as int?) ?? 0; - - set _retryAttempt(final int attempt) => extra['auth_retry_attempt'] = attempt; -} diff --git a/lib/utils/dio_wordpress_client.dart b/lib/utils/dio_wordpress_client.dart deleted file mode 100644 index 2ff069d..0000000 --- a/lib/utils/dio_wordpress_client.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:dio/dio.dart'; - -class DioWordpressClient { - static const String url = 'https://www.utem.cl/wp-json/wp/v2'; - - static Dio _dio = Dio(BaseOptions( - baseUrl: url, - )); - - static Dio get initDio => _dio - //..interceptors.add(DioCacheManager(CacheConfig(baseUrl: url)).interceptor) - ; -} diff --git a/lib/utils/http/functions.dart b/lib/utils/http/functions.dart new file mode 100644 index 0000000..ff02eec --- /dev/null +++ b/lib/utils/http/functions.dart @@ -0,0 +1,57 @@ +import 'package:dio/dio.dart'; +import 'package:dio_http_cache/dio_http_cache.dart'; +import 'package:mi_utem/config/constants.dart'; +import 'package:mi_utem/config/logger.dart'; +import 'package:mi_utem/models/preferencia.dart'; +import 'package:mi_utem/utils/http/http_client.dart'; + + +/// Función para realizar solicitudes mediante el authClient. +/// Esta función además genera un [CacheOptions] con opciones personalizadas por defecto. +/// También esta función utiliza `$apiUrl/v1/` como prefijo. +/// +/// [path] es el endpoint al que se desea acceder. No puede tener el prefijo `/v1/` o `/`. +/// [method] es el método HTTP a utilizar. +/// [data] es un mapa con los datos a enviar. +/// [options] son las opciones personalizadas para la solicitud. +/// [forceRefresh] fuerza a que se realice una solicitud nueva. +/// [ttl] es el tiempo que se guardará en caché la solicitud. +Future authClientRequest(String path, { + String method = "GET", + Map? headers, + Map? data, + String? contentType, + ResponseType? responseType, + Options? options, + bool forceRefresh = false, + Duration? ttl = const Duration(days: 7), +}) async => await HttpClient.authClient.request("$apiUrl/v1/$path", + data: data, + options: options ?? buildCacheOptions(ttl ?? Duration(days: 7), + forceRefresh: forceRefresh, + primaryKey: 'miutem', + subKey: path, + maxStale: const Duration(days: 14), + options: Options( + method: method, + headers: headers, + contentType: contentType, + responseType: responseType, + ), + ), +); + +Future isOffline() async { + bool offlineMode = (await Preferencia.isOffline.getAsBool(defaultValue: false, guardar: true)); + + try { + final response = await HttpClient.dioClient.head(apiUrl); + offlineMode = !"${response.statusCode}".startsWith("2"); + } catch (e) { + logger.e("[HttpRequest#isOffline]: Error al conectar con la API", e); + offlineMode = true; + } + + await Preferencia.isOffline.set(offlineMode.toString()); + return offlineMode; +} \ No newline at end of file diff --git a/lib/utils/http/http_client.dart b/lib/utils/http/http_client.dart new file mode 100644 index 0000000..d45f8e5 --- /dev/null +++ b/lib/utils/http/http_client.dart @@ -0,0 +1,36 @@ +import 'package:dio/dio.dart'; +import 'package:dio_http_cache/dio_http_cache.dart'; +import 'package:mi_utem/config/constants.dart'; +import 'package:mi_utem/utils/http/interceptors/auth_interceptor.dart'; +import 'package:mi_utem/utils/http/interceptors/error_interceptor.dart'; +import 'package:mi_utem/utils/http/interceptors/headers_interceptor.dart'; +import 'package:mi_utem/utils/http/interceptors/log_interceptor.dart'; +import 'package:mi_utem/utils/http/interceptors/offline_mode_interceptor.dart'; + +class HttpClient { + + static final DioCacheManager cacheManager = DioCacheManager(CacheConfig( + baseUrl: apiUrl, + defaultMaxAge: const Duration(days: 7), + defaultMaxStale: const Duration(days: 14), + )); + + static final Dio dioClient = Dio(BaseOptions(baseUrl: apiUrl))..interceptors.addAll([ + HeadersInterceptor(), + logInterceptor, + ]); + + static final Dio httpClient = dioClient..interceptors.addAll([ + OfflineModeInterceptor(), + cacheManager.interceptor, + errorInterceptor, + ]); + + static final Dio authClient = httpClient..interceptors.add(AuthInterceptor()); + + static Future clearCache() async { + await cacheManager.deleteByPrimaryKey("miutem"); + await cacheManager.clearExpired(); + await cacheManager.clearAll(); + } +} \ No newline at end of file diff --git a/lib/utils/http/interceptors/auth_interceptor.dart b/lib/utils/http/interceptors/auth_interceptor.dart new file mode 100644 index 0000000..164acc4 --- /dev/null +++ b/lib/utils/http/interceptors/auth_interceptor.dart @@ -0,0 +1,85 @@ +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/services/auth_service.dart'; +import 'package:mi_utem/utils/http/http_client.dart'; + +class AuthInterceptor extends QueuedInterceptor { + + /* Cantidad de reintentos máximos si se recibe un error 401 */ + final int retries; + final _authService = Get.find(); + + AuthInterceptor({ + this.retries = 3, + }); + + @override + Future onRequest(final RequestOptions options, final RequestInterceptorHandler handler) async { + try { + if(!options.headers.containsKey("authorization")) { + final user = await _authService.getUser(); + final token = user?.token; + if(token != null) { + options._setAuthenticationHeader(token); + } + } + + return handler.next(options); + } catch (e) { + return handler.reject(DioError( + requestOptions: options, + error: e, + )); + } + } + + @override + Future onError(final DioError err, final ErrorInterceptorHandler handler) async { + final options = err.requestOptions; + + if (err.response?.statusCode != 401) { + return super.onError(err, handler); + } + + final attempt = err.requestOptions._retryAttempt + 1; + if (attempt > retries) { + await _onErrorRefreshingToken(); + return super.onError(err, handler); + } + + err.requestOptions._retryAttempt = attempt; + await Future.delayed(const Duration(seconds: 1)); + + /* Forzar el refresco de la token de autenticación */ + try { + await _authService.isLoggedIn(forceRefresh: true); + final token = (await _authService.getUser())?.token; + + if(token == null) { + await _onErrorRefreshingToken(); + return super.onError(err, handler); + } + + options._setAuthenticationHeader(token); + final response = await HttpClient.httpClient.fetch(options); + return handler.resolve(response); + } on DioError catch (e) { + super.onError(e, handler); + } catch (e) { + super.onError( + DioError(requestOptions: options, error: e), + handler, + ); + } + } + + Future _onErrorRefreshingToken() async => await _authService.logout(); +} + +extension AuthRequestOptionsX on RequestOptions { + void _setAuthenticationHeader(final String token) => headers['Authorization'] = 'Bearer $token'; + + int get _retryAttempt => (extra['auth_retry_attempt'] as int?) ?? 0; + + set _retryAttempt(final int attempt) => extra['auth_retry_attempt'] = attempt; +} \ No newline at end of file diff --git a/lib/utils/http/interceptors/error_interceptor.dart b/lib/utils/http/interceptors/error_interceptor.dart new file mode 100644 index 0000000..d61a02b --- /dev/null +++ b/lib/utils/http/interceptors/error_interceptor.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; +import 'package:mi_utem/config/logger.dart'; +import 'package:mi_utem/models/exceptions/custom_exception.dart'; + +InterceptorsWrapper errorInterceptor = InterceptorsWrapper( + onError: (DioError err, ErrorInterceptorHandler handler) { + if(err.response?.statusCode == 401) { + return handler.next(err); + } + + final json = err.response?.data ?? {}; + if(json is Map && json.containsKey("error")) { + return handler.reject(DioError(requestOptions: err.requestOptions, error: CustomException.fromJson(json as Map))); + } + + logger.e("[ErrorInterceptor]: ${err.message}", err.error, err.stackTrace); + final error = DioError(requestOptions: err.requestOptions, error: CustomException.fromJson({ + "mensaje": err.response?.statusMessage ?? "Ocurrió un error inesperado. Por favor, inténtalo nuevamente.", + "error": err.message, + "codigoHttp": err.response?.statusCode, + "codigoInterno": 0.0, + }), response: err.response, type: err.type); + error.stackTrace = err.stackTrace; + return handler.reject(error); + }, +); \ No newline at end of file diff --git a/lib/utils/http/interceptors/headers_interceptor.dart b/lib/utils/http/interceptors/headers_interceptor.dart new file mode 100644 index 0000000..8379286 --- /dev/null +++ b/lib/utils/http/interceptors/headers_interceptor.dart @@ -0,0 +1,19 @@ +import 'package:dio/dio.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class HeadersInterceptor extends Interceptor { + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + final headers = options.headers; + final info = await PackageInfo.fromPlatform(); + + headers['User-Agent'] = 'App/MiUTEM v${info.version} (${info.buildNumber})'; + if(options.data != null) { + headers['Content-Type'] = 'application/json'; + } + options.headers = headers; + + super.onRequest(options, handler); + } +} \ No newline at end of file diff --git a/lib/utils/http/interceptors/log_interceptor.dart b/lib/utils/http/interceptors/log_interceptor.dart new file mode 100644 index 0000000..3abcebb --- /dev/null +++ b/lib/utils/http/interceptors/log_interceptor.dart @@ -0,0 +1,20 @@ +import 'package:dio/dio.dart'; +import 'package:mi_utem/config/logger.dart'; + +InterceptorsWrapper logInterceptor = InterceptorsWrapper( + onRequest: (RequestOptions options, RequestInterceptorHandler handler) { + final now = DateTime.now(); + logger.d("[HttpClient - ${now.toIso8601String()}]: ${options.method.toUpperCase()} ${options.uri}"); + options.extra["request_created_at"] = now.toIso8601String(); + return handler.next(options); + }, + onResponse: (Response response, ResponseInterceptorHandler handler) { + final now = DateTime.now(); + final requestCreatedAt = DateTime.tryParse(response.requestOptions.extra["request_created_at"] as String); + if(requestCreatedAt != null) { + final difference = now.difference(requestCreatedAt).inMilliseconds; + logger.d("[HttpClient - $requestCreatedAt]: ${response.statusCode} > ${response.requestOptions.method.toUpperCase()} ${response.requestOptions.uri} ${difference}ms"); + } + return handler.next(response); + }, +); \ No newline at end of file diff --git a/lib/utils/http/interceptors/offline_mode_interceptor.dart b/lib/utils/http/interceptors/offline_mode_interceptor.dart new file mode 100644 index 0000000..2b562b1 --- /dev/null +++ b/lib/utils/http/interceptors/offline_mode_interceptor.dart @@ -0,0 +1,55 @@ +import 'package:dio/dio.dart'; +import 'package:dio_http_cache/dio_http_cache.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/models/exceptions/custom_exception.dart'; +import 'package:mi_utem/models/preferencia.dart'; +import 'package:mi_utem/utils/http/functions.dart'; +import 'package:mi_utem/widgets/snackbar.dart'; + +class OfflineModeInterceptor extends Interceptor { + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + bool offlineMode = (await Preferencia.isOffline.getAsBool(defaultValue: false, guardar: true)); + bool _forceRefresh = options.extra.containsKey(DIO_CACHE_KEY_FORCE_REFRESH) && options.extra[DIO_CACHE_KEY_FORCE_REFRESH] == true; + if(!offlineMode || !_forceRefresh) { + return handler.next(options); + } + + // Revisa si sigue offline realizando solicitud a la API (solo head) + offlineMode = await isOffline(); + + if(!offlineMode) { // Si vuelve la conexión + return handler.next(options); + } + + if(_forceRefresh) { + final context = Get.context; + if(context != null) { + showErrorSnackbar(context, "No se puede realizar la solicitud en modo Offline. Por favor revisa tu conexión a internet e intenta nuevamente."); + } + } + + options.extra[DIO_CACHE_KEY_FORCE_REFRESH] = offlineMode; + + return handler.next(options); + } + + @override + void onError(DioError err, ErrorInterceptorHandler handler) async { + bool _forceRefresh = err.requestOptions.extra.containsKey(DIO_CACHE_KEY_FORCE_REFRESH) && err.requestOptions.extra[DIO_CACHE_KEY_FORCE_REFRESH] == true; + bool _offlineMode = (await Preferencia.isOffline.get(defaultValue: "false", guardar: true)) == "true"; + if(!_forceRefresh || !_offlineMode) { + return super.onError(err, handler); + } + + final error = DioError(requestOptions: err.requestOptions, error: CustomException.fromJson({ + "mensaje": "No se puede realizar la solicitud en modo Offline. Por favor revisa tu conexión a internet e intenta nuevamente.", + "error": "No se puede realizar la solicitud en modo Offline. Por favor revisa tu conexión a internet e intenta nuevamente.", + "codigoHttp": 400, + "codigoInterno": 0.0, + }), type: DioErrorType.cancel, response: err.response); + + return handler.next(error); + } +} \ No newline at end of file diff --git a/lib/utils/string_utils.dart b/lib/utils/string_utils.dart new file mode 100644 index 0000000..e6ac5ba --- /dev/null +++ b/lib/utils/string_utils.dart @@ -0,0 +1,3 @@ +String firstLetterUpperCase(String s) => s.isNotEmpty ? s[0].toUpperCase() + (s.length > 1 ? s.substring(1) : "").toLowerCase() : ""; + +String capitalize(String s) => s.split(" ").map(firstLetterUpperCase).join(" "); \ No newline at end of file diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart new file mode 100644 index 0000000..9da0462 --- /dev/null +++ b/lib/utils/utils.dart @@ -0,0 +1,38 @@ +/// Esta función ejecuta la función `op` si es que el objeto no es nulo, además retorna lo que retorne la función op. +/// +/// Modo de uso: +/// +/// ```dart +/// num? numeroPosiblementeNulo = 5; +/// num? resultado = let(numeroPosiblementeNulo, (numero) => numero*2); +/// print(resultado); // 10 +/// ``` +/// +/// Ahora, si el objeto es nulo, la función retornará null. +/// +/// Además puedes especificar los tipos de datos para ayudar a los IDEs a inferir el tipo de dato de retorno. +/// Ejemplo: +/// +/// ```dart +/// String? fechaPosiblementeNula = "2021-10-10"; +/// DateTime? fecha = let(fechaPosiblementeNula, (fecha) => DateTime.tryParse(fecha)); +/// print(fecha); // 2021-10-10 00:00:00.000 +/// ``` +V? let(K? object, V Function(K) op) => object != null ? op(object) : null; + +/// Esta función muestra una nota en formato de 1 o 2 decimales dependiendo de si es un 3.95 o no. +String? formatoNota(num? nota) => nota == 3.95 ? nota?.toStringAsFixed(2) : nota?.toStringAsFixed(1); + +/// Esta extensión rotará los elementos de una lista "rotándolos" al final las veces que sea especificado. +/// Ejemplo: +/// ```dart +/// List lista = [1, 2, 3, 4, 5]; +/// print(lista.rotate(2)); // [3, 4, 5, 1, 2] +/// +/// // Si el número es negativo, se rotará hacia la izquierda +/// print(lista.rotate(-2)); // [4, 5, 1, 2, 3] +/// ``` +extension RotateList on List { + + List rotate(int count) => count > 0 ? (this.sublist(count)..addAll(this.sublist(0, count))) : (this.sublist(this.length + count)..addAll(this.sublist(0, this.length + count))); +} \ No newline at end of file diff --git a/lib/widgets/acerca/club/acerca_app.dart b/lib/widgets/acerca/club/acerca_app.dart new file mode 100644 index 0000000..14297a1 --- /dev/null +++ b/lib/widgets/acerca/club/acerca_app.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:mi_utem/services/remote_config/remote_config.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +class AcercaApp extends StatelessWidget { + + const AcercaApp({ + super.key, + }); + + @override + Widget build(BuildContext context) => FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (ctx, snapshot) { + final data = snapshot.data; + if(snapshot.connectionState != ConnectionState.done || data == null){ + return const SizedBox(); + } + + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: Text("Acerca de la App", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + color: Colors.grey[700], + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 20), + MarkdownBody( + selectable: false, + styleSheet: MarkdownStyleSheet( + textAlign: WrapAlignment.start, + p: TextStyle( + fontSize: 16, + color: Colors.grey[700], + ), + ), + data: RemoteConfigService.miutemAcercaDeLaApp.replaceAll("%version", data.version) + .replaceAll("%compilacion", data.buildNumber == data.version ? "1" : data.buildNumber) + .replaceAll("%paquete", data.packageName) + .replaceAll("%nombre", data.appName), + ), + ], + ), + ), + ); + }, + ); +} \ No newline at end of file diff --git a/lib/widgets/acerca/club/acerca_club.dart b/lib/widgets/acerca/club/acerca_club.dart new file mode 100644 index 0000000..20f28bb --- /dev/null +++ b/lib/widgets/acerca/club/acerca_club.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:mi_utem/services/remote_config/remote_config.dart'; +import 'package:mi_utem/widgets/image/default_network_image.dart'; + +import 'acerca_club_redes.dart'; + +class AcercaClub extends StatelessWidget { + + const AcercaClub({ + super.key, + }); + + @override + Widget build(BuildContext context) => Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + SizedBox( + width: 150, + height: 150, + child: DefaultNetworkImage(url: RemoteConfigService.clubLogo), + ), + const SizedBox(height: 20), + Text( + RemoteConfigService.clubNombre, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + color: Colors.grey[700], + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + MarkdownBody( + selectable: false, + styleSheet: MarkdownStyleSheet( + textAlign: WrapAlignment.center, + p: TextStyle( + fontSize: 16, + color: Colors.grey[700], + ), + ), + data: RemoteConfigService.clubDescripcion, + ), + const SizedBox(height: 20), + const AcercaClubRedes(), + ], + ), + ), + ); +} \ No newline at end of file diff --git a/lib/widgets/acerca/club/acerca_club_desarrolladores.dart b/lib/widgets/acerca/club/acerca_club_desarrolladores.dart new file mode 100644 index 0000000..e3cd747 --- /dev/null +++ b/lib/widgets/acerca/club/acerca_club_desarrolladores.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:mi_utem/models/user/persona.dart'; +import 'package:mi_utem/services/analytics_service.dart'; +import 'package:mi_utem/services/remote_config/remote_config.dart'; +import 'package:mi_utem/widgets/image/image_view_screen.dart'; +import 'package:mi_utem/widgets/profile_photo.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class AcercaClubDesarrolladores extends StatelessWidget { + + const AcercaClubDesarrolladores({ + super.key, + }); + + @override + Widget build(BuildContext context) => Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + ), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text("Desarrolladores", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20, + color: Colors.grey[700], + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 10), + ...jsonDecode(RemoteConfigService.miutemDesarrolladores).map((developer) => Container( + padding: EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + ProfilePhoto( + fotoUrl: developer['fotoUrl'], + iniciales: Persona(nombreCompleto: developer['nombre']).iniciales, + onImageTap: (context, imageProvider) { + AnalyticsService.logEvent("acerca_person_image_tap", parameters: { + "persona": developer['nombre'], + }); + Navigator.push(context, MaterialPageRoute(builder: (ctx) => ImageViewScreen(imageProvider: imageProvider), fullscreenDialog: true)); + }, + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(developer["nombre"], + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey[800], + fontSize: 16, + ), + ), + Text(developer["rol"], + style: TextStyle( + fontSize: 16, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 5), + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: (developer['redes'] as List).map((socialNetwork) => Container( + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Color(socialNetwork["color"]), + ), + child: InkWell( + customBorder: + const CircleBorder(), + onTap: () async { + AnalyticsService.logEvent("acerca_person_social_tap", + parameters: { + "persona": developer['nombre'], + "red": socialNetwork['nombre'], + }, + ); + await launchUrl(Uri.parse(socialNetwork["url"])); + }, + child: Container( + padding: const EdgeInsets.all(8), + decoration: const BoxDecoration(shape: BoxShape.circle), + child: Icon( + IconDataBrands(socialNetwork["icono"]), + size: 15, + color: Colors.white, + ), + ), + ), + )).toList(), + ), + ], + ), + ), + ], + ), + )).toList() + ], + ), + ), + ); +} \ No newline at end of file diff --git a/lib/widgets/acerca/club/acerca_club_redes.dart b/lib/widgets/acerca/club/acerca_club_redes.dart new file mode 100644 index 0000000..0482ec2 --- /dev/null +++ b/lib/widgets/acerca/club/acerca_club_redes.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:mi_utem/services/analytics_service.dart'; +import 'package:mi_utem/services/remote_config/remote_config.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class AcercaClubRedes extends StatelessWidget { + + const AcercaClubRedes({ + super.key, + }); + + @override + Widget build(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: (jsonDecode(RemoteConfigService.clubRedes) as List).map((red) => Container( + margin: EdgeInsets.symmetric(horizontal: 5), + decoration: new BoxDecoration( + shape: BoxShape.circle, + color: Color(red["color"]), + ), + child: InkWell( + customBorder: CircleBorder(), + onTap: () async { + AnalyticsService.logEvent("acerca_club_social_tap", parameters: { + "red": red['nombre'], + }); + await launchUrl(Uri.parse(red["url"])); + }, + child: Container( + padding: const EdgeInsets.all(10), + decoration: const BoxDecoration(shape: BoxShape.circle), + child: Icon(IconDataBrands(red["icono"]), + size: 20, + color: Colors.white, + ), + ), + ), + )).toList(), + ); + +} \ No newline at end of file diff --git a/lib/widgets/acerca_aplicacion_content.dart b/lib/widgets/acerca/dialog/acerca_aplicacion_content.dart similarity index 100% rename from lib/widgets/acerca_aplicacion_content.dart rename to lib/widgets/acerca/dialog/acerca_aplicacion_content.dart diff --git a/lib/widgets/acerca_dialog.dart b/lib/widgets/acerca/dialog/acerca_dialog.dart similarity index 58% rename from lib/widgets/acerca_dialog.dart rename to lib/widgets/acerca/dialog/acerca_dialog.dart index 9aba895..eb12c4d 100644 --- a/lib/widgets/acerca_dialog.dart +++ b/lib/widgets/acerca/dialog/acerca_dialog.dart @@ -1,14 +1,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/widgets/acerca_aplicacion_content.dart'; +import 'package:mi_utem/widgets/acerca/dialog/acerca_aplicacion_content.dart'; +import 'package:mi_utem/widgets/acerca/dialog/acerca_dialog_action_button.dart'; class AcercaDialog extends StatefulWidget { - AcercaDialog({ - Key? key, - }) : super(key: key); + const AcercaDialog({ + super.key, + }); @override State createState() => _AcercaDialogState(); @@ -76,39 +75,14 @@ class _AcercaDialogState extends State { preTitulo: "Antes de empezar...", titulo: "Bienvenido a Mi UTEM", ), - Wrap( - spacing: 10, - runSpacing: 10, - crossAxisAlignment: WrapCrossAlignment.center, - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - TextButton( - child: Text( - _isActive - ? "Podrás cerrar en $_timeLeft" - : "Saber más", - style: TextStyle(color: Colors.white), - ), - onPressed: _isActive - ? null - : () { - Get.back(); - Get.toNamed( - Routes.about, - ); - }, - ), - ], - ), + AcercaDialogActionButton(isActive: _isActive, timeLeft: _timeLeft), if (!_isActive) OutlinedButton( - child: Text( - "Cerrar", - style: TextStyle(color: Get.theme.primaryColor), + child: Text("Cerrar", + style: TextStyle(color: Theme.of(context).primaryColor), ), onPressed: () { - Get.back(); + Navigator.pop(context); }, ), Container(height: 20), diff --git a/lib/widgets/acerca/dialog/acerca_dialog_action_button.dart b/lib/widgets/acerca/dialog/acerca_dialog_action_button.dart new file mode 100644 index 0000000..a8cbfaa --- /dev/null +++ b/lib/widgets/acerca/dialog/acerca_dialog_action_button.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/screens/acerca_screen.dart'; + +class AcercaDialogActionButton extends StatefulWidget { + + final bool isActive; + final int timeLeft; + + const AcercaDialogActionButton({ + super.key, + required this.isActive, + required this.timeLeft, + }); + + @override + State createState() => _AcercaDialogActionButtonState(); +} + +class _AcercaDialogActionButtonState extends State { + + @override + Widget build(BuildContext context) => Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + TextButton( + child: Text(widget.isActive ? "Podrás cerrar en ${widget.timeLeft}" : "Saber más", + style: const TextStyle(color: Colors.white), + ), + onPressed: () { + if (!widget.isActive) { + Navigator.pop(context); + Navigator.push(context, MaterialPageRoute(builder: (context) => AcercaScreen())); + } + }, + ), + ], + ); +} \ No newline at end of file diff --git a/lib/widgets/acerca_screen.dart b/lib/widgets/acerca_screen.dart deleted file mode 100644 index 30a7613..0000000 --- a/lib/widgets/acerca_screen.dart +++ /dev/null @@ -1,259 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/models/usuario.dart'; -import 'package:mi_utem/services/analytics_service.dart'; -import 'package:mi_utem/services/remote_config/remote_config.dart'; -import 'package:mi_utem/widgets/acerca_aplicacion_content.dart'; -import 'package:mi_utem/widgets/custom_app_bar.dart'; -import 'package:mi_utem/widgets/default_network_image.dart'; -import 'package:mi_utem/widgets/image_view_screen.dart'; -import 'package:mi_utem/widgets/profile_photo.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class AcercaScreen extends StatelessWidget { - AcercaScreen({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.grey[200], - appBar: CustomAppBar( - title: Text( - "Acerca de Mi UTEM", - ), - ), - body: SingleChildScrollView( - child: Padding( - padding: EdgeInsets.all(10), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15.0), - ), - child: Container( - padding: EdgeInsets.all(20), - child: Column( - children: [ - Container( - width: 150, - height: 150, - child: DefaultNetworkImage( - url: RemoteConfigService.clubLogo, - )), - Container(height: 20), - Text( - RemoteConfigService.clubNombre, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 20, - color: Colors.grey[700], - fontWeight: FontWeight.bold, - ), - ), - Container(height: 20), - MarkdownBody( - selectable: false, - styleSheet: MarkdownStyleSheet( - textAlign: WrapAlignment.center, - p: TextStyle( - fontSize: 16, - color: Colors.grey[700], - ), - ), - data: RemoteConfigService.clubDescripcion, - ), - Container(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: jsonDecode(RemoteConfigService.clubRedes) - .map( - (red) => Container( - margin: EdgeInsets.symmetric(horizontal: 5), - decoration: new BoxDecoration( - shape: BoxShape.circle, - color: Color(red["color"]), - ), - child: InkWell( - customBorder: CircleBorder(), - onTap: () async { - AnalyticsService.logEvent( - "acerca_club_social_tap", - parameters: { - "red": red['nombre'], - }, - ); - await launchUrl(Uri.parse(red["url"])); - }, - child: Container( - padding: const EdgeInsets.all(10), - decoration: new BoxDecoration( - shape: BoxShape.circle, - ), - child: Icon( - IconDataBrands(red["icono"]), - size: 20, - color: Colors.white, - ), - ), - ), - ), - ) - .toList(), - ), - ], - ), - ), - ), - Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15.0), - ), - child: AcercaAplicacionContent(), - ), - Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15.0), - ), - child: Container( - padding: EdgeInsets.all(20), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: EdgeInsets.symmetric(horizontal: 10), - child: Text( - "Desarrolladores", - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 20, - color: Colors.grey[700], - fontWeight: FontWeight.bold, - ), - ), - ), - Container(height: 10), - ...jsonDecode(RemoteConfigService.miutemDesarrolladores) - .map( - (creador) => Container( - padding: EdgeInsets.symmetric(vertical: 10), - child: Row( - children: [ - ProfilePhoto( - usuario: Usuario( - nombres: creador['nombre'], - fotoUrl: creador['fotoUrl']), - onImageTap: (context, imageProvider) { - AnalyticsService.logEvent( - "acerca_person_image_tap", - parameters: { - "persona": creador['nombre'], - }, - ); - Get.to( - () => ImageViewScreen( - imageProvider: imageProvider, - ), - routeName: Routes.imageView, - ); - }), - Container(width: 20), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - creador["nombre"], - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.grey[800], - fontSize: 16, - ), - ), - Text( - creador["rol"], - style: TextStyle( - fontSize: 16, - color: Colors.grey[700], - ), - ), - Container(height: 5), - Row( - mainAxisAlignment: - MainAxisAlignment.start, - children: creador['redes'] - .map( - (red) => Container( - margin: - EdgeInsets.only(right: 8), - decoration: new BoxDecoration( - shape: BoxShape.circle, - color: Color(red["color"]), - ), - child: InkWell( - customBorder: - CircleBorder(), - onTap: () async { - AnalyticsService.logEvent( - "acerca_person_social_tap", - parameters: { - "persona": - creador['nombre'], - "red": red['nombre'], - }, - ); - await launchUrl( - Uri.parse(red["url"]), - ); - }, - child: Container( - padding: - const EdgeInsets.all( - 8), - decoration: - new BoxDecoration( - shape: BoxShape.circle, - ), - child: Icon( - IconDataBrands( - red["icono"]), - size: 15, - color: Colors.white, - ), - ), - ), - ), - ) - .toList(), - ), - ], - ), - ), - ], - ), - ), - ) - .toList() - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/asistencia_chart.dart b/lib/widgets/asignatura/asistencia_chart.dart similarity index 78% rename from lib/widgets/asistencia_chart.dart rename to lib/widgets/asignatura/asistencia_chart.dart index 34a33f1..bf7de4c 100644 --- a/lib/widgets/asistencia_chart.dart +++ b/lib/widgets/asignatura/asistencia_chart.dart @@ -1,10 +1,8 @@ import 'dart:math'; +import 'package:community_charts_flutter/community_charts_flutter.dart' as charts; import 'package:flutter/material.dart'; - -import 'package:charts_flutter/flutter.dart' as charts; - -import 'package:mi_utem/models/asignatura.dart'; +import 'package:mi_utem/models/asignaturas/asistencia.dart'; import 'package:mi_utem/themes/theme.dart'; extension StringExtension on Color { @@ -73,37 +71,41 @@ class AsistenciaChart extends StatelessWidget { height: 15, width: 15, ), - Container(width: 10), + const SizedBox(width: 10), Text("Asistidos (${asistencia!.asistidos})"), ], ), - Container(height: 5), + const SizedBox(height: 5), Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - decoration: BoxDecoration( - color: MainTheme.reprobadoColor, - borderRadius: BorderRadius.circular(15)), - height: 15, - width: 15), - Container(width: 10), + decoration: BoxDecoration( + color: MainTheme.reprobadoColor, + borderRadius: BorderRadius.circular(15), + ), + height: 15, + width: 15, + ), + const SizedBox(width: 10), Text("No asistidos (${asistencia!.noAsistidos})"), ], ), - Container(height: 5), + const SizedBox(height: 5), Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - decoration: BoxDecoration( - color: MainTheme.disabledColor, - borderRadius: BorderRadius.circular(15)), - height: 15, - width: 15), - Container(width: 10), + decoration: BoxDecoration( + color: MainTheme.disabledColor, + borderRadius: BorderRadius.circular(15), + ), + height: 15, + width: 15, + ), + const SizedBox(width: 10), Text("Sin registro (${asistencia!.sinRegistro})"), ], ), diff --git a/lib/widgets/asignatura/lista/asignatura_list_tile.dart b/lib/widgets/asignatura/lista/asignatura_list_tile.dart new file mode 100644 index 0000000..98d8651 --- /dev/null +++ b/lib/widgets/asignatura/lista/asignatura_list_tile.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; +import 'package:mi_utem/models/carrera.dart'; +import 'package:mi_utem/repositories/grades_repository.dart'; +import 'package:mi_utem/screens/asignatura/detalle/asignatura_detalle_screen.dart'; +import 'package:mi_utem/themes/theme.dart'; +import 'package:mi_utem/widgets/loading/loading_dialog.dart'; + +class AsignaturaListTile extends StatefulWidget { + final Carrera carrera; + final Asignatura asignatura; + + const AsignaturaListTile({ + super.key, + required this.carrera, + required this.asignatura, + }); + + @override + State createState() => _AsignaturaListTileState(); +} + +class _AsignaturaListTileState extends State { + late Asignatura asignatura; + + @override + void initState() { + asignatura = widget.asignatura; + super.initState(); + } + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: Card( + child: InkWell( + onTap: () async { + showLoadingDialog(context); + final grades = await Get.find().getGrades(carreraId: widget.carrera.id, asignaturaId: asignatura.id); + Navigator.pop(context); + Navigator.push(context, MaterialPageRoute(builder: (ctx) => AsignaturaDetalleScreen( + carrera: widget.carrera, + asignatura: asignatura.copyWith(grades: grades), + ))); + }, + child: Container( + padding: const EdgeInsets.all(20), + width: double.infinity, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("${asignatura.nombre}", + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MainTheme.theme.textTheme.titleMedium, + textAlign: TextAlign.start, + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("${asignatura.codigo}"), + Text("${asignatura.tipoHora}"), + ], + ) + ], + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/asignatura/lista/lista_asignaturas.dart b/lib/widgets/asignatura/lista/lista_asignaturas.dart new file mode 100644 index 0000000..109f6b7 --- /dev/null +++ b/lib/widgets/asignatura/lista/lista_asignaturas.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; +import 'package:mi_utem/models/carrera.dart'; +import 'package:mi_utem/widgets/asignatura/lista/asignatura_list_tile.dart'; + +class ListaAsignaturas extends StatelessWidget { + final Carrera carrera; + final List asignaturas; + + const ListaAsignaturas({ + super.key, + required this.carrera, + required this.asignaturas, + }); + + @override + Widget build(BuildContext context) => ListView.builder( + physics: AlwaysScrollableScrollPhysics(), + itemBuilder: (BuildContext context, int i) => AsignaturaListTile( + carrera: carrera, + asignatura: asignaturas[i], + ), + itemCount: asignaturas.length, + ); +} diff --git a/lib/widgets/asignatura/lista/sin_asignaturas_mensaje.dart b/lib/widgets/asignatura/lista/sin_asignaturas_mensaje.dart new file mode 100644 index 0000000..fd74001 --- /dev/null +++ b/lib/widgets/asignatura/lista/sin_asignaturas_mensaje.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/widgets/custom_error_widget.dart'; + +class SinAsignaturasMensaje extends StatelessWidget { + + final String mensaje, emoji; + + const SinAsignaturasMensaje({ + super.key, + required this.mensaje, + required this.emoji, + }); + + @override + Widget build(BuildContext context) => Center( + child: SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: CustomErrorWidget( + emoji: emoji, + title: mensaje, + ), + ), + ); +} \ No newline at end of file diff --git a/lib/widgets/asignatura/modals/user_modal.dart b/lib/widgets/asignatura/modals/user_modal.dart new file mode 100644 index 0000000..0752429 --- /dev/null +++ b/lib/widgets/asignatura/modals/user_modal.dart @@ -0,0 +1,80 @@ +import 'package:clipboard/clipboard.dart'; +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/user/user.dart'; +import 'package:mi_utem/widgets/profile_photo.dart'; +import 'package:mi_utem/widgets/snackbar.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class UserModal extends StatelessWidget { + final User user; + + const UserModal({ + super.key, + required this.user, + }); + + @override + Widget build(BuildContext context) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Stack( + children: [ + Container( + alignment: Alignment.topCenter, + margin: const EdgeInsets.only(top: 80), + child: Card( + margin: const EdgeInsets.all(20), + child: ListView( + padding: const EdgeInsets.only(bottom: 10, top: 20), + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + ListTile( + title: Text("Nombre Completo", + style: TextStyle(color: Colors.grey), + ), + onLongPress: () async { + await FlutterClipboard.copy(user.nombreCompleto); + showTextSnackbar(context, title: "¡Copiado!", message: "Correo copiado al portapapeles"); + }, + subtitle: Text(user.nombreCompletoCapitalizado, + style: TextStyle( + color: Colors.grey[900], + fontSize: 18, + ), + ), + ), + Divider(height: 5), + ListTile( + title: Text("Correo", + style: TextStyle(color: Colors.grey), + ), + onLongPress: () async { + await FlutterClipboard.copy(user.correoUtem!); + showTextSnackbar(context, title: "¡Copiado!", message: "Correo copiado al portapapeles"); + }, + onTap: () async { + await launchUrl(Uri.parse("mailto:${user.correoUtem ?? ""}")); + }, + subtitle: Text(user.correoUtem!, + style: TextStyle( + color: Colors.grey[900], + fontSize: 18, + ), + ), + ), + ], + ), + ), + ), + Center( + child: ProfilePhoto( + fotoUrl: user.fotoUrl, + iniciales: user.iniciales, + radius: 60, + editable: false, + ), + ), + ], + ), + ); +} diff --git a/lib/widgets/asignatura/notas_tab/labeled_nota_display.dart b/lib/widgets/asignatura/notas_tab/labeled_nota_display.dart new file mode 100644 index 0000000..c3ec31b --- /dev/null +++ b/lib/widgets/asignatura/notas_tab/labeled_nota_display.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_masked_text/flutter_masked_text.dart'; +import 'package:mi_utem/themes/theme.dart'; + +class LabeledNotaDisplayWidget extends StatefulWidget { + final String label; + final num? nota; + final String? hint; + + const LabeledNotaDisplayWidget({ + super.key, + required this.label, + this.nota, + this.hint, + }); + + @override + _LabeledNotaDisplayWidgetState createState() => _LabeledNotaDisplayWidgetState(); +} + +class _LabeledNotaDisplayWidgetState extends State { + @override + Widget build(BuildContext context) { + final notaController = MaskedTextController( + mask: '0.0', + text: widget.nota?.toStringAsFixed(1) ?? "", + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + widget.label, + style: TextStyle(fontSize: 16), + ), + Container( + width: 60, + margin: EdgeInsets.only(left: 15), + child: TextField( + controller: notaController, + textAlign: TextAlign.center, + enabled: false, + decoration: InputDecoration( + hintText: widget.hint, + disabledBorder: MainTheme.theme.inputDecorationTheme.border!.copyWith(borderSide: BorderSide(color: Colors.transparent)), + ), + keyboardType: TextInputType.numberWithOptions(decimal: true), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/asignatura/notas_tab/nota_final_display.dart b/lib/widgets/asignatura/notas_tab/nota_final_display.dart new file mode 100644 index 0000000..8c5b0b6 --- /dev/null +++ b/lib/widgets/asignatura/notas_tab/nota_final_display.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +class NotaFinalDisplayWidget extends StatelessWidget { + final num? notaFinal; + final String? estado; + + NotaFinalDisplayWidget({ + super.key, + this.notaFinal, + this.estado, + }); + + @override + Widget build(BuildContext context) => Column( + children: [ + Text(notaFinal?.toStringAsFixed(1) ?? "S/N", + style: TextStyle( + fontSize: 40, + fontWeight: FontWeight.bold, + ), + ), + Text(estado ?? "---", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ); +} diff --git a/lib/widgets/asignatura/notas_tab/nota_list_item.dart b/lib/widgets/asignatura/notas_tab/nota_list_item.dart new file mode 100644 index 0000000..b10789f --- /dev/null +++ b/lib/widgets/asignatura/notas_tab/nota_list_item.dart @@ -0,0 +1,74 @@ +import 'package:extended_masked_text/extended_masked_text.dart'; +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/evaluacion/evaluacion.dart'; +import 'package:mi_utem/themes/theme.dart'; +import 'package:mi_utem/utils/utils.dart'; + +class NotaListItem extends StatelessWidget { + final IEvaluacion evaluacion; + + const NotaListItem({ + super.key, + required this.evaluacion, + }); + + @override + Widget build(BuildContext context) => Flex( + direction: Axis.horizontal, + mainAxisSize: MainAxisSize.max, + children: [ + SizedBox( + width: 90, + child: Text(evaluacion.descripcion ?? "Nota", + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 16), + Flexible( + flex: 3, + child: Center( + child: TextField( + enabled: false, + controller: MaskedTextController( + mask: "0.0", + text: formatoNota(evaluacion.nota) ?? "", + ), + decoration: InputDecoration( + hintText: "--", + disabledBorder: MainTheme.theme.inputDecorationTheme.border!.copyWith( + borderSide: const BorderSide( + color: Colors.transparent, + ), + ), + ), + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox(width: 16), + Flexible( + flex: 4, + child: Center( + child: TextField( + controller: MaskedTextController( + mask: "000", + text: evaluacion.porcentaje?.toStringAsFixed(0) ?? "", + ), + enabled: false, + textAlign: TextAlign.center, + decoration: InputDecoration( + hintText: "Peso", + suffixText: "%", + disabledBorder: MainTheme.theme.inputDecorationTheme.border!.copyWith( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + ), + ), + ), + ), + const SizedBox(width: 20), + ], + ); +} diff --git a/lib/widgets/asignatura/notas_tab/notas_display.dart b/lib/widgets/asignatura/notas_tab/notas_display.dart new file mode 100644 index 0000000..2190aa0 --- /dev/null +++ b/lib/widgets/asignatura/notas_tab/notas_display.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/widgets/asignatura/notas_tab/labeled_nota_display.dart'; +import 'package:mi_utem/widgets/asignatura/notas_tab/nota_final_display.dart'; + +class NotasDisplayWidget extends StatelessWidget { + final num? notaFinal; + final num? notaExamen; + final num? notaPresentacion; + final String? estado; + final Color? colorPorEstado; + + const NotasDisplayWidget({ + super.key, + this.notaFinal, + this.notaExamen, + this.notaPresentacion, + this.estado, + this.colorPorEstado, + }); + + @override + @override + Widget build(BuildContext context) => Card( + child: Row( + children: [ + Container( + height: 130, + width: 10, + color: colorPorEstado, + ), + Expanded( + child: Padding( + padding: EdgeInsets.fromLTRB(15, 20, 20, 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + NotaFinalDisplayWidget(notaFinal: notaFinal, estado: estado), + SizedBox(width: 10), + Container(height: 80, width: 0.5, color: Colors.grey), + SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + LabeledNotaDisplayWidget( + label: "Examen", + nota: notaExamen, + hint: "--", + ), + Container(height: 10), + LabeledNotaDisplayWidget( + label: "Presentación", + nota: notaPresentacion, + hint: "--", + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); +} diff --git a/lib/widgets/avance_ramo_card.dart b/lib/widgets/avance_malla/avance_ramo_card.dart similarity index 100% rename from lib/widgets/avance_ramo_card.dart rename to lib/widgets/avance_malla/avance_ramo_card.dart diff --git a/lib/widgets/carrera_list_item.dart b/lib/widgets/avance_malla/carrera_list_item.dart similarity index 100% rename from lib/widgets/carrera_list_item.dart rename to lib/widgets/avance_malla/carrera_list_item.dart diff --git a/lib/widgets/info_boletin_card.dart b/lib/widgets/avance_malla/info_boletin_card.dart similarity index 100% rename from lib/widgets/info_boletin_card.dart rename to lib/widgets/avance_malla/info_boletin_card.dart diff --git a/lib/widgets/avance_malla/semestre_boletin_card.dart b/lib/widgets/avance_malla/semestre_boletin_card.dart new file mode 100644 index 0000000..310aaa1 --- /dev/null +++ b/lib/widgets/avance_malla/semestre_boletin_card.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; + +class SemestreBoletinCard extends StatelessWidget { + final String? semestre; + final int? aprobados; + final int? reprobados; + final int? convalidados; + final double? promedio; + final List? ramos; + + SemestreBoletinCard( + {Key? key, + this.semestre, + this.aprobados, + this.reprobados, + this.convalidados, + this.promedio, + this.ramos}) + : super(key: key); + + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.only(left: 10.0, right: 10.0), + child: Card( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0),), + color: Color(0xFFD8BFD8), + child: Container( + padding: EdgeInsets.symmetric(vertical: 10), + width: MediaQuery.of(context).size.width * 0.4, + child: Column( + children: [ + Text("Promedio", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black.withOpacity(0.6), + ), + ), + Text(promedio.toString(), + style: TextStyle(color: Colors.black.withOpacity(0.6)), + ), + ], + ), + ), + ), + Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0)), + color: Color(0xFF98FB98), + child: Container( + padding: EdgeInsets.symmetric(vertical: 10), + width: MediaQuery.of(context).size.width * 0.4, + child: Column( + children: [ + Text("Aprobados", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black.withOpacity(0.6), + ), + ), + Text(aprobados.toString(), + style: TextStyle( + color: Colors.black.withOpacity(0.6), + ), + ), + ], + ), + ), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10.0),), + color: Color(0xFFFFC0CB), + child: Container( + padding: EdgeInsets.symmetric(vertical: 10), + width: MediaQuery.of(context).size.width * 0.4, + child: Column( + children: [ + Text("Reprobados", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black.withOpacity(0.6), + ), + ), + Text(reprobados.toString(), + style: TextStyle( + color: Colors.black.withOpacity(0.6), + ), + ), + ], + ), + ), + ), + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10.0), + ), + color: Color(0xFFFFDEAD), + child: Container( + padding: EdgeInsets.symmetric(vertical: 10), + width: MediaQuery.of(context).size.width * 0.4, + child: Column( + children: [ + Text("Convalidados", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black.withOpacity(0.6), + ), + ), + Text(convalidados.toString(), + style: TextStyle( + color: Colors.black.withOpacity(0.6), + ), + ), + ], + ), + ), + ), + ], + ), + Column( + children: [ + Container( + padding: EdgeInsets.symmetric(vertical: 20), + child: new Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: MediaQuery.of(context).size.width * 0.5, + child: Text("Asignatura", + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + Container( + width: MediaQuery.of(context).size.width * 0.2, + child: Text("Estado", + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + Container( + width: MediaQuery.of(context).size.width * 0.1, + child: Text("Nota", + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + new ListView.separated( + shrinkWrap: true, + physics: ClampingScrollPhysics(), + itemCount: ramos!.length, + separatorBuilder: (BuildContext context, int i) => Divider( + indent: 20, + endIndent: 20, + ), + itemBuilder: (BuildContext context, int i) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: EdgeInsets.only(top: 5, bottom: 5), + width: MediaQuery.of(context).size.width * 0.5, + child: Text(ramos![i]["nombre"], + textAlign: TextAlign.center, + ), + ), + Container( + padding: EdgeInsets.only(top: 5, bottom: 5), + width: MediaQuery.of(context).size.width * 0.2, + child: Text(ramos![i]["estado"].substring(0, 1), + textAlign: TextAlign.center, + ), + ), + Container( + padding: EdgeInsets.only(top: 5, bottom: 5), + width: MediaQuery.of(context).size.width * 0.1, + child: Text(ramos![i]["nota"].toString(), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ], + ), + ], + ), + color: Colors.white, + ), + ); +} diff --git a/lib/widgets/banner.dart b/lib/widgets/banner.dart deleted file mode 100644 index bbd416d..0000000 --- a/lib/widgets/banner.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:hexcolor/hexcolor.dart'; -import 'package:mi_utem/services/analytics_service.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class IBanner { - final String id; - final String name; - final Color backgroundColor; - final String url; - final String imageUrl; - - IBanner({ - required this.id, - required this.name, - required this.backgroundColor, - required this.url, - required this.imageUrl, - }); - - factory IBanner.fromJson(Map json) { - return IBanner( - id: json["id"], - name: json["name"], - backgroundColor: HexColor(json["backgroundColor"]), - url: json["url"], - imageUrl: json["imageUrl"], - ); - } - - static List fromJsonList(dynamic json) { - if (json == null) { - return []; - } - List list = []; - for (var item in json) { - list.add(IBanner.fromJson(item)); - } - return list; - } -} - -class MiUtemBanner extends StatelessWidget { - final IBanner banner; - - const MiUtemBanner({ - Key? key, - required this.banner, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) => Container( - width: constraints.maxWidth, - height: constraints.maxWidth * 0.4, - child: InkWell( - onTap: _onTap, - child: Card( - margin: EdgeInsets.zero, - color: banner.backgroundColor, - borderOnForeground: false, - child: CachedNetworkImage( - imageUrl: banner.imageUrl, - fit: BoxFit.cover, - ), - ), - ), - ), - ); - } - - void _onTap() async { - AnalyticsService.logEvent( - "banner_tap", - parameters: { - "banner_id": banner.id, - }, - ); - await launchUrl(Uri.parse(banner.url)); - } -} diff --git a/lib/widgets/bloque_periodo_card.dart b/lib/widgets/bloque_periodo_card.dart deleted file mode 100644 index ea06ff7..0000000 --- a/lib/widgets/bloque_periodo_card.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:mi_utem/themes/theme.dart'; - -class BloquePeriodoCard extends StatelessWidget { - final String? inicio; - final String? intermedio; - final String? fin; - final double height; - final double width; - final bool active; - final Color backgroundColor; - - BloquePeriodoCard({ - Key? key, - required this.inicio, - required this.intermedio, - required this.fin, - required this.height, - required this.width, - this.backgroundColor = MainTheme.lightGrey, - this.active = false, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: backgroundColor, - ), - height: height, - width: width, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - inicio!, - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.black54, - fontSize: 18, - fontWeight: active ? FontWeight.bold : FontWeight.normal, - ), - ), - Text( - intermedio!, - maxLines: 3, - textAlign: TextAlign.center, - style: TextStyle( - letterSpacing: 0.5, - wordSpacing: 1, - color: Colors.black54, - fontSize: 14, - fontWeight: active ? FontWeight.bold : FontWeight.normal, - ), - ), - Text( - fin!, - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.black54, - fontSize: 18, - fontWeight: active ? FontWeight.bold : FontWeight.normal, - ), - ) - ], - ), - ); - } -} diff --git a/lib/widgets/bloque_ramo_card.dart b/lib/widgets/bloque_ramo_card.dart deleted file mode 100644 index c69b0b6..0000000 --- a/lib/widgets/bloque_ramo_card.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:dotted_border/dotted_border.dart'; -import 'package:flutter/material.dart'; -import 'package:mi_utem/controllers/horario_controller.dart'; -import 'package:mi_utem/models/horario.dart'; -import 'package:mi_utem/services/analytics_service.dart'; -import 'package:mi_utem/themes/theme.dart'; - -class ClassBlockCard extends StatelessWidget { - final BloqueHorario? block; - final double width; - final double height; - final double internalMargin; - final Color textColor; - - ClassBlockCard({ - Key? key, - required this.block, - required this.width, - required this.height, - this.internalMargin = 0, - this.textColor = Colors.white, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - height: height, - width: width, - child: Padding( - padding: EdgeInsets.all(internalMargin), - child: block?.asignatura == null - ? _EmptyBlock() - : _ClassBlock( - block: block!, - width: width, - height: height, - textColor: textColor, - onTap: _onTap, - onLongPress: _onLongPress, - ), - ), - ); - } - - _onTap(BloqueHorario block) { - AnalyticsService.logEvent( - "horario_class_block_tap", - parameters: { - "asignatura": block.asignatura?.nombre, - "codigo": block.asignatura?.codigo, - }, - ); - } - - _onLongPress(BloqueHorario block) { - AnalyticsService.logEvent( - "horario_class_block_long_press", - parameters: { - "asignatura": block.asignatura?.nombre, - "codigo": block.asignatura?.codigo, - }, - ); - } -} - -class _EmptyBlock extends StatelessWidget { - const _EmptyBlock({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: MainTheme.lightGrey, - borderRadius: BorderRadius.circular(15), - ), - child: DottedBorder( - strokeWidth: 2, - color: MainTheme.grey, - borderType: BorderType.RRect, - radius: Radius.circular(15), - child: Container(), - ), - ); - } -} - -class _ClassBlock extends StatelessWidget { - final BloqueHorario block; - final double width; - final double height; - final Color textColor; - final Color? color; - final void Function(BloqueHorario)? onTap; - final void Function(BloqueHorario)? onLongPress; - - const _ClassBlock({ - Key? key, - required this.block, - required this.width, - required this.height, - required this.textColor, - this.color = Colors.teal, - this.onTap, - this.onLongPress, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: HorarioController.to.getColor(block.asignatura!) ?? color, - borderRadius: BorderRadius.circular(15), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(15), - onTap: onTap != null ? () => onTap?.call(block) : null, - onLongPress: - onLongPress != null ? () => onLongPress?.call(block) : null, - child: Column( - children: [ - HorarioText.classCode( - block.codigo!, - color: textColor, - ), - HorarioText.className( - block.asignatura!.nombre!.toUpperCase(), - color: textColor, - ), - HorarioText.classLocation( - block.sala ?? "Sin sala", - color: textColor, - ), - ], - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - ), - ), - ), - ); - } -} diff --git a/lib/widgets/calculadora_notas/display_notas_widget.dart b/lib/widgets/calculadora_notas/display_notas_widget.dart new file mode 100644 index 0000000..5099f1b --- /dev/null +++ b/lib/widgets/calculadora_notas/display_notas_widget.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/widgets/calculadora_notas/modo_simulacion_widget.dart'; +import 'package:mi_utem/widgets/calculadora_notas/nota_examen_display_widget.dart'; +import 'package:mi_utem/widgets/calculadora_notas/nota_final_display_widget.dart'; +import 'package:mi_utem/widgets/calculadora_notas/nota_presentacion_display_widget.dart'; + +class DisplayNotasWidget extends StatelessWidget { + + const DisplayNotasWidget({ + super.key, + }); + + @override + Widget build(BuildContext context) => Card( + child: Stack( + alignment: Alignment.center, + children: [ + const ModoSimulacionWidget(), + Container( + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 30), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const NotaFinalDisplayWidget(), + const SizedBox(width: 10), + Container( + height: 80, + width: 0.5, + color: Colors.grey, + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const NotaExamenDisplayWidget(), + const SizedBox(height: 10), + const NotaPresentacionDisplayWidget(), + ], + ), + ], + ), + ), + ], + ), + ); +} diff --git a/lib/widgets/calculadora_notas/editar_notas_widget.dart b/lib/widgets/calculadora_notas/editar_notas_widget.dart new file mode 100644 index 0000000..7be86c1 --- /dev/null +++ b/lib/widgets/calculadora_notas/editar_notas_widget.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/controllers/calculator_controller.dart'; +import 'package:mi_utem/models/evaluacion/evaluacion.dart'; +import 'package:mi_utem/services/analytics_service.dart'; +import 'package:mi_utem/widgets/calculadora_notas/modo_simulacion_widget.dart'; +import 'package:mi_utem/widgets/calculadora_notas/notas_calculadora_widget.dart'; + +class EditarNotasWidget extends StatelessWidget { + + const EditarNotasWidget({ + super.key, + }); + + @override + Widget build(BuildContext context) => Card( + child: Stack( + alignment: Alignment.center, + children: [ + ModoSimulacionWidget(), + Container( + padding: EdgeInsets.all(20), + child: Column( + children: [ + const NotasCalculadoraWidget(), + const SizedBox(height: 16), + TextButton( + onPressed: () { + AnalyticsService.logEvent("calculator_add_grade"); + Get.find().addGrade(IEvaluacion( + nota: null, + porcentaje: null, + )); + }, + child: const Text("Agregar nota"), + ), + ], + ), + ), + ], + ), + ); + +} diff --git a/lib/widgets/calculadora_notas/modo_simulacion_widget.dart b/lib/widgets/calculadora_notas/modo_simulacion_widget.dart new file mode 100644 index 0000000..239bde8 --- /dev/null +++ b/lib/widgets/calculadora_notas/modo_simulacion_widget.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class ModoSimulacionWidget extends StatelessWidget { + + const ModoSimulacionWidget({ + super.key, + }); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(20), + width: double.infinity, + child: RotationTransition( + turns: const AlwaysStoppedAnimation(-20/360), + child: Text("Modo simulación".toUpperCase(), + style: TextStyle( + color: Colors.grey[200], + fontWeight: FontWeight.bold, + fontSize: 25, + ), + textAlign: TextAlign.center, + ), + ), + ); +} diff --git a/lib/widgets/calculadora_notas/nota_examen_display_widget.dart b/lib/widgets/calculadora_notas/nota_examen_display_widget.dart new file mode 100644 index 0000000..caf87a2 --- /dev/null +++ b/lib/widgets/calculadora_notas/nota_examen_display_widget.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/controllers/calculator_controller.dart'; +import 'package:mi_utem/themes/theme.dart'; +import 'package:mi_utem/utils/utils.dart'; + +class NotaExamenDisplayWidget extends StatelessWidget { + + const NotaExamenDisplayWidget({ + super.key, + }); + + @override + Widget build(BuildContext context) { + CalculatorController _calculatorController = Get.find(); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text("Examen", + style: TextStyle(fontSize: 16), + ), + Container( + width: 80, + margin: const EdgeInsets.only(left: 15), + child: Obx(() => TextField( + controller: _calculatorController.examGradeTextFieldController, + textAlign: TextAlign.center, + onChanged: (String value) => _calculatorController.setExamGrade(double.tryParse(value.replaceAll(",", ".")), updateTextController: false), + enabled: _calculatorController.canTakeExam, + decoration: InputDecoration( + hintText: formatoNota(_calculatorController.minimumRequiredExamGrade) ?? "--", + filled: !_calculatorController.canTakeExam, + fillColor: Colors.grey.withOpacity(0.2), + disabledBorder: MainTheme.theme.inputDecorationTheme.border!.copyWith( + borderSide: BorderSide( + color: Colors.grey[300]!, + ), + ), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + TextInputFormatter.withFunction((prev, input) { + final val = input.text; + if(val.isEmpty) { // Si está vacío, no hacer nada + return input; + } + + final firstDigit = int.tryParse(val[0]); + if(firstDigit != null && (firstDigit < 1 || firstDigit > 7)) { // Si el primer dígito es menor a 1 o mayor a 7, no hacer nada + return prev; + } + + if(val.length == 1) { + return input; + } + + final secondDigit = int.tryParse(val[1]); + if(secondDigit != null && ((secondDigit < 0 || secondDigit > 9) || (firstDigit == 7 && secondDigit > 0)) || val.length > 3) { // Si el segundo dígito es menor a 0 o mayor a 9, o si el primer dígito es 7 y el segundo dígito es mayor a 0, no hacer nada + return prev; + } + + return input; + }), + ], + )), + ), + ], + ); + } +} diff --git a/lib/widgets/calculadora_notas/nota_final_display_widget.dart b/lib/widgets/calculadora_notas/nota_final_display_widget.dart new file mode 100644 index 0000000..cf83d54 --- /dev/null +++ b/lib/widgets/calculadora_notas/nota_final_display_widget.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/controllers/calculator_controller.dart'; +import 'package:mi_utem/utils/utils.dart'; + +class NotaFinalDisplayWidget extends StatelessWidget { + + const NotaFinalDisplayWidget({ + super.key, + }); + + @override + Widget build(BuildContext context) { + CalculatorController _calculatorController = Get.find(); + + return Column( + children: [ + Obx(() => Text(formatoNota(_calculatorController.calculatedFinalGrade) ?? '--', + style: TextStyle( + fontSize: 40, + fontWeight: FontWeight.bold, + ), + )), + ], + ); + } +} diff --git a/lib/widgets/calculadora_notas/nota_list_item.dart b/lib/widgets/calculadora_notas/nota_list_item.dart new file mode 100644 index 0000000..2fdbb61 --- /dev/null +++ b/lib/widgets/calculadora_notas/nota_list_item.dart @@ -0,0 +1,162 @@ +import 'package:extended_masked_text/extended_masked_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/controllers/calculator_controller.dart'; +import 'package:mi_utem/models/evaluacion/evaluacion.dart'; +import 'package:mi_utem/themes/theme.dart'; +import 'package:mi_utem/utils/utils.dart'; + +class NotaListItem extends StatelessWidget { + final IEvaluacion evaluacion; + final bool editable; + final TextEditingController? gradeController; + final TextEditingController? percentageController; + final Function(IEvaluacion)? onChanged; + final Function()? onDelete; + + const NotaListItem({ + super.key, + required this.evaluacion, + this.editable = false, + this.gradeController, + this.percentageController, + this.onChanged, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final defaultGradeController = MaskedTextController( + mask: "0.0", + text: formatoNota(evaluacion.nota) ?? "", + ); + final defaultPercentageController = MaskedTextController( + mask: "000", + text: evaluacion.porcentaje?.toStringAsFixed(0) ?? "", + ); + + final showSuggestedGrade = editable; + CalculatorController calculatorController = Get.find(); + + + return Flex( + direction: Axis.horizontal, + mainAxisSize: MainAxisSize.max, + children: [ + SizedBox( + width: 90, + child: Text(evaluacion.descripcion ?? "Nota", + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 16), + Flexible( + flex: 3, + child: Center( + child: TextField( + controller: gradeController ?? defaultGradeController, + enabled: editable, + onChanged: (String value) { + final grade = double.tryParse(value.replaceAll(",", ".")); + + final changedGrade = evaluacion.copyWith(nota: grade); + changedGrade.nota = grade; + + onChanged?.call(changedGrade); + }, + textAlign: TextAlign.center, + decoration: InputDecoration( + hintText: showSuggestedGrade ? (formatoNota(calculatorController.suggestedGrade) ?? "--") : "--", + disabledBorder: MainTheme.theme.inputDecorationTheme.border!.copyWith( + borderSide: const BorderSide( + color: Colors.transparent, + ), + ), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + TextInputFormatter.withFunction((prev, input) { + final val = input.text; + if(val.isEmpty) { // Si está vacío, no hacer nada + return input; + } + + final firstDigit = int.tryParse(val[0]); + if(firstDigit != null && (firstDigit < 1 || firstDigit > 7)) { // Si el primer dígito es menor a 1 o mayor a 7, no hacer nada + return prev; + } + + if(val.length == 1) { + return input; + } + + final secondDigit = int.tryParse(val[1]); + if(secondDigit != null && ((secondDigit < 0 || secondDigit > 9) || (firstDigit == 7 && secondDigit > 0)) || val.length > 3) { // Si el segundo dígito es menor a 0 o mayor a 9, o si el primer dígito es 7 y el segundo dígito es mayor a 0, no hacer nada + return prev; + } + + return input; + }), + ], + ), + ), + ), + const SizedBox(width: 16), + Flexible( + flex: 4, + child: Center( + child: Obx(() => TextField( + controller: percentageController ?? defaultPercentageController, + textAlign: TextAlign.center, + onChanged: (String value) { + final percentage = int.tryParse(value); + final changedGrade = evaluacion.copyWith(porcentaje: percentage); + changedGrade.porcentaje = percentage; + onChanged?.call(changedGrade); + }, + enabled: editable, + decoration: InputDecoration( + hintText: calculatorController.suggestedPercentage?.toStringAsFixed(0) ?? "Peso", + suffixText: "%", + disabledBorder: MainTheme.theme.inputDecorationTheme.border!.copyWith( + borderSide: BorderSide( + color: Colors.transparent, + ), + ), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(3), + // Solo permitir números entre 0 y 100 + TextInputFormatter.withFunction((prev, input) { + final val = input.text; + if(val.isEmpty) { // Si está vacío, no hacer nada + return input; + } + + final number = int.tryParse(val); + if(number == null) { // Si no es un número, no hacer nada + return prev; + } + + return number > 100 ? prev : input; + }), + + ], + )), + ), + ), + const SizedBox(width: 20), + if (onDelete != null) GestureDetector( + onTap: () => onDelete?.call(), + child: Icon( + Icons.delete, + color: Theme.of(context).primaryColor, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/calculadora_notas/nota_presentacion_display_widget.dart b/lib/widgets/calculadora_notas/nota_presentacion_display_widget.dart new file mode 100644 index 0000000..7269b02 --- /dev/null +++ b/lib/widgets/calculadora_notas/nota_presentacion_display_widget.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/controllers/calculator_controller.dart'; +import 'package:mi_utem/themes/theme.dart'; +import 'package:mi_utem/utils/utils.dart'; + +class NotaPresentacionDisplayWidget extends StatelessWidget { + const NotaPresentacionDisplayWidget({ + super.key, + }); + + @override + Widget build(BuildContext context) { + final _calculatorController = Get.find(); + return Row( + children: [ + const Text("Pres.", + style: TextStyle(fontSize: 16), + ), + Container( + width: 80, + margin: const EdgeInsets.only(left: 15), + child: Obx(() => TextField( + controller: TextEditingController(text: formatoNota(_calculatorController.calculatedPresentationGrade) ?? ""), + textAlign: TextAlign.center, + enabled: false, + decoration: InputDecoration( + hintText: "Nota", + disabledBorder: MainTheme.theme.inputDecorationTheme.border!.copyWith( + borderSide: const BorderSide( + color: Colors.transparent, + ), + ), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + )), + ), + ], + ); + } +} diff --git a/lib/widgets/calculadora_notas/notas_calculadora_widget.dart b/lib/widgets/calculadora_notas/notas_calculadora_widget.dart new file mode 100644 index 0000000..0345f1e --- /dev/null +++ b/lib/widgets/calculadora_notas/notas_calculadora_widget.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/controllers/calculator_controller.dart'; +import 'package:mi_utem/models/evaluacion/evaluacion.dart'; +import 'package:mi_utem/services/analytics_service.dart'; +import 'package:mi_utem/widgets/calculadora_notas/nota_list_item.dart'; + +class NotasCalculadoraWidget extends StatelessWidget { + + const NotasCalculadoraWidget({ + super.key, + }); + + + @override + Widget build(BuildContext context) { + final CalculatorController calculatorController = Get.find(); + + return Obx(() => ListView.separated( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + separatorBuilder: (context, index) => const SizedBox(height: 10), + itemBuilder: (context, idx) => NotaListItem( + evaluacion: IEvaluacion.fromRemote(calculatorController.partialGrades[idx]), + editable: true, + gradeController: calculatorController.gradeTextFieldControllers[idx], + percentageController: calculatorController.percentageTextFieldControllers[idx], + onChanged: (evaluacion) => calculatorController.updateGradeAt(idx, evaluacion), + onDelete: () { + AnalyticsService.logEvent("calculator_delete_grade"); + calculatorController.removeGradeAt(idx); + }, + ), + itemCount: calculatorController.partialGrades.length, + )); + } +} diff --git a/lib/widgets/credencial_card.dart b/lib/widgets/credencial/credencial_card.dart similarity index 79% rename from lib/widgets/credencial_card.dart rename to lib/widgets/credencial/credencial_card.dart index 4674760..4a8d3be 100644 --- a/lib/widgets/credencial_card.dart +++ b/lib/widgets/credencial/credencial_card.dart @@ -3,9 +3,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_uxcam/flutter_uxcam.dart'; -import 'package:get/get.dart'; import 'package:mi_utem/models/carrera.dart'; -import 'package:mi_utem/models/usuario.dart'; +import 'package:mi_utem/models/user/user.dart'; import 'package:mi_utem/services/remote_config/remote_config.dart'; import 'package:mi_utem/themes/theme.dart'; import 'package:mi_utem/widgets/flip_widget.dart'; @@ -14,21 +13,21 @@ import 'package:simple_gesture_detector/simple_gesture_detector.dart'; import 'package:url_launcher/url_launcher.dart'; class CredencialCard extends StatelessWidget { - final Usuario? usuario; + final User? user; final Carrera? carrera; final FlipController? controller; final Function(SwipeDirection?)? onFlip; - CredencialCard( - {Key? key, - required this.usuario, - required this.carrera, - this.controller, - this.onFlip}) - : super(key: key); + CredencialCard({ + super.key, + required this.user, + required this.carrera, + this.controller, + this.onFlip + }); - Widget _buildFront() { - double altoBanner = Get.mediaQuery.size.height * 0.2; + Widget _buildFront(BuildContext context) { + double altoBanner = MediaQuery.of(context).size.height * 0.2; return Card( elevation: 1, clipBehavior: Clip.antiAlias, @@ -40,7 +39,8 @@ class CredencialCard extends StatelessWidget { Container( margin: EdgeInsets.only(top: altoBanner - 40), child: ProfilePhoto( - usuario: usuario, + fotoUrl: user?.fotoUrl, + iniciales: user?.iniciales ?? "N/N", radius: 50, borderWidth: 5, ), @@ -63,7 +63,7 @@ class CredencialCard extends StatelessWidget { padding: EdgeInsets.only(bottom: 20), child: Image.asset( 'assets/images/utem_logo_negativo.png', - width: Get.mediaQuery.size.width * 0.4, + width: MediaQuery.of(context).size.width * 0.4, ), ), ), @@ -74,8 +74,7 @@ class CredencialCard extends StatelessWidget { color: Colors.white, child: Column( children: [ - Text( - usuario!.nombreCompleto!, + Text(user?.nombreCompleto ?? "N/N", maxLines: 2, style: TextStyle( fontSize: 18, @@ -85,8 +84,7 @@ class CredencialCard extends StatelessWidget { textAlign: TextAlign.center, ), OccludeWrapper( - child: Text( - usuario!.rut?.formateado(true) ?? "Sin RUT", + child: Text(user?.rut?.toString() ?? "Sin RUT", style: TextStyle(fontSize: 18), textAlign: TextAlign.center, ), @@ -94,10 +92,7 @@ class CredencialCard extends StatelessWidget { Spacer(), Divider(height: 1), Spacer(), - Text( - (carrera?.nombre == null || carrera!.nombre!.isEmpty - ? "Sin carrera" - : carrera?.nombre!)!, + Text(("${carrera?.nombre}".isEmpty ? "Sin carrera" : "${carrera?.nombre}"), maxLines: 3, style: TextStyle( color: MainTheme.primaryDarkColor, @@ -105,47 +100,46 @@ class CredencialCard extends StatelessWidget { ), textAlign: TextAlign.center, ), - if (usuario!.rut != null || true) Spacer(), - if (usuario!.rut != null || true) - Column( - children: [ - Container( - decoration: BoxDecoration( - color: Colors.white, - border: Border( - top: BorderSide(color: Colors.grey), - left: BorderSide(color: Colors.grey), - bottom: BorderSide(color: Colors.grey), - right: BorderSide(color: Colors.grey), - ), - ), - padding: EdgeInsets.symmetric( - vertical: 5, - horizontal: 10, + const Spacer(), + Column( + children: [ + Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border( + top: BorderSide(color: Colors.grey), + left: BorderSide(color: Colors.grey), + bottom: BorderSide(color: Colors.grey), + right: BorderSide(color: Colors.grey), ), - child: OccludeWrapper( - child: BarcodeWidget( - barcode: Barcode.code39(), - data: "${usuario!.rut!.numero}", - width: 200, - height: 50, - drawText: false, - ), + ), + padding: EdgeInsets.symmetric( + vertical: 5, + horizontal: 10, + ), + child: OccludeWrapper( + child: BarcodeWidget( + barcode: Barcode.code39(), + data: "${user?.rut}", + width: 200, + height: 50, + drawText: false, ), ), - Container(height: 10), - MarkdownBody( - selectable: false, - styleSheet: MarkdownStyleSheet( - textAlign: WrapAlignment.center, - p: TextStyle( - fontSize: 12, - ), + ), + Container(height: 10), + MarkdownBody( + selectable: false, + styleSheet: MarkdownStyleSheet( + textAlign: WrapAlignment.center, + p: TextStyle( + fontSize: 12, ), - data: RemoteConfigService.credencialBarras, ), - ], - ) + data: RemoteConfigService.credencialBarras, + ), + ], + ), ], ), ), @@ -280,7 +274,7 @@ class CredencialCard extends StatelessWidget { return AspectRatio( aspectRatio: 53.98 / 85.60, child: FlipWidget( - front: _buildFront(), + front: _buildFront(context), back: _buildRear(), controller: controller, onFlip: onFlip, diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 1880c31..ad9a629 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -4,146 +4,163 @@ import 'package:badges/badges.dart' as badge; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:mdi/mdi.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/models/usuario.dart'; +import 'package:mi_utem/models/pair.dart'; +import 'package:mi_utem/models/preferencia.dart'; +import 'package:mi_utem/models/user/user.dart'; +import 'package:mi_utem/screens/acerca_screen.dart'; +import 'package:mi_utem/screens/asignatura/asignaturas_lista_screen.dart'; +import 'package:mi_utem/screens/credencial_screen.dart'; +import 'package:mi_utem/screens/horario/horario_screen.dart'; +import 'package:mi_utem/screens/main_screen.dart'; +import 'package:mi_utem/screens/perfil/perfil_screen.dart'; import 'package:mi_utem/services/auth_service.dart'; import 'package:mi_utem/services/remote_config/remote_config.dart'; import 'package:mi_utem/services/review_service.dart'; import 'package:mi_utem/themes/theme.dart'; +import 'package:mi_utem/utils/utils.dart'; import 'package:mi_utem/widgets/profile_photo.dart'; class CustomDrawer extends StatelessWidget { - final Usuario usuario; - CustomDrawer({Key? key, required this.usuario}) : super(key: key); - String? _getRoute(String? name) { + + const CustomDrawer({ + super.key, + }); + + Widget? _getRoute(String? name) { switch (name) { case "Perfil": - return Routes.perfil; + return PerfilScreen(); case "Asignaturas": - return Routes.asignaturas; + return AsignaturasListaScreen(); case "Horario": - return Routes.horario; + return HorarioScreen(); case "Credencial": - return Routes.credencial; + return CredencialScreen(); // case "Docentes": // return DocentesScreen(); // break; default: - return Routes.home; + return MainScreen(); } } - List? get _menu { - return jsonDecode(RemoteConfigService.drawerMenu) - .where((e) => e['mostrar'] == true) - .toList(); - } + List get _menu => (jsonDecode(RemoteConfigService.drawerMenu).where((e) => e['mostrar'] == true).toList()) ?? []; @override Widget build(BuildContext context) { + final _authService = Get.find(); + return Drawer( semanticLabel: "Abrir menú", child: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return SingleChildScrollView( + builder: (context, BoxConstraints constraints) => SingleChildScrollView( child: ConstrainedBox( constraints: constraints.copyWith( minHeight: constraints.maxHeight, maxHeight: double.infinity, ), - child: IntrinsicHeight( - child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - UserAccountsDrawerHeader( - accountEmail: Text( - usuario.correoUtem ?? usuario.correoPersonal ?? ""), - accountName: Text( - usuario.nombreCompleto ?? "", - style: - TextStyle(fontSize: 16, fontWeight: FontWeight.w500), - ), - currentAccountPicture: ProfilePhoto( - usuario: usuario, - radius: 30, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomLeft, - end: Alignment.topRight, - colors: [ - MainTheme.utemAzul, - MainTheme.utemVerde, - ], + child: FutureBuilder>( + future: () async { + final user = await _authService.getUser(); + final apodo = await Preferencia.apodo.get(); + return Pair(apodo, user); + }(), + builder: (context, snapshot) { + final pair = snapshot.data; + String? alias = pair?.a; + User? user = pair?.b; + if(!snapshot.hasData || snapshot.hasError || user == null) { + return Container(); + } + + return IntrinsicHeight( + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + UserAccountsDrawerHeader( + accountEmail: Text(user.correoUtem ?? user.correoPersonal ?? ""), + accountName: Text(alias ?? user.nombreCompleto, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + currentAccountPicture: ProfilePhoto( + fotoUrl: user.fotoUrl, + iniciales: user.iniciales, + radius: 30, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomLeft, + end: Alignment.topRight, + colors: [MainTheme.utemAzul, MainTheme.utemVerde], + ), + ), ), - ), - ), - for (var e in _menu!) - ListTile( - leading: Icon(IconData(e["icono"]["codePoint"], - fontFamily: e["icono"]["fontFamily"], - fontPackage: e["icono"]["fontPackage"])), - title: Text(e["nombre"]), - trailing: e["esNuevo"] - ? badge.Badge( + SafeArea( + top: false, + child: Column( + children: _menu.map((e) => ListTile( + leading: Icon(IconData(e["icono"]["codePoint"], + fontFamily: e["icono"]["fontFamily"], + fontPackage: e["icono"]["fontPackage"], + )), + title: Text(e["nombre"]), + trailing: let(e["esNuevo"], (esNuevo) => badge.Badge( + showBadge: esNuevo, shape: badge.BadgeShape.square, borderRadius: BorderRadius.circular(10), - padding: EdgeInsets.symmetric( - horizontal: 6, vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), elevation: 0, - badgeContent: Text( - 'Nuevo', + badgeContent: const Text("Nuevo", style: TextStyle( color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold, ), ), - ) - : null, - onTap: () async { - String? route = _getRoute(e["nombre"]); - if (route != null) { - Get.toNamed(route); - ReviewService.checkAndRequestReview(); - } - }, - ), - Expanded( - child: SafeArea( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Divider(height: 5), - ListTile( - leading: Icon(Mdi.heart), - title: Text('Acerca de Mi UTEM'), - onTap: () async { - await Get.toNamed(Routes.about); - ReviewService.checkAndRequestReview(); - }, - ), - ListTile( - leading: Icon(Mdi.closeCircle), - title: Text('Cerrar sesión'), - onTap: () async { - await AuthService.logOut(); - - await Get.offAllNamed(Routes.home); - }, + )), + onTap: () => let(_getRoute(e["nombre"]), (route) { + Navigator.push(context, MaterialPageRoute(builder: (ctx) => route)); + ReviewService.checkAndRequestReview(context); + }), + )).toList(), + ), + ), + Expanded( + child: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Divider(height: 5), + ListTile( + leading: const Icon(Mdi.heart), + title: const Text("Acerca de Mi UTEM"), + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (ctx) => AcercaScreen())); + ReviewService.checkAndRequestReview(context); + }, + ), + ListTile( + leading: const Icon(Mdi.closeCircle), + title: const Text('Cerrar sesión'), + onTap: () async => await _authService.logout(context: context), + ), + ], ), - ], + ), ), - ), + ], ), - ], - ), + ); + }, ), ), - ); - }), + ), + ), ); } } diff --git a/lib/widgets/custom_error_widget.dart b/lib/widgets/custom_error_widget.dart index f3b332e..483f843 100644 --- a/lib/widgets/custom_error_widget.dart +++ b/lib/widgets/custom_error_widget.dart @@ -6,40 +6,37 @@ class CustomErrorWidget extends StatelessWidget { final String title; final Object? error; - CustomErrorWidget({ - Key? key, + const CustomErrorWidget({ + super.key, this.emoji = "😕", this.title = "Ocurrió un error inesperado", this.error, - }) : super(key: key); + }); @override Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.all(20), + return Padding( + padding: const EdgeInsets.all(20), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - emoji, + Text(emoji, textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( fontSize: 50, ), ), - Container(height: 15), - Text( - title, + const SizedBox(height: 15), + Text(title, textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 18, ), ), if (error != null) ...[ - Container(height: 15), - Text( - error.toString(), + const SizedBox(height: 15), + Text("$error", textAlign: TextAlign.center, ), ], diff --git a/lib/widgets/default_network_image.dart b/lib/widgets/default_network_image.dart deleted file mode 100644 index b73470a..0000000 --- a/lib/widgets/default_network_image.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/widgets/image_view_screen.dart'; - -class DefaultNetworkImage extends StatelessWidget { - final String? url; - - DefaultNetworkImage({ - this.url, - }); - - @override - Widget build(BuildContext context) { - return Container( - height: double.infinity, - width: double.infinity, - child: CachedNetworkImage( - imageUrl: url ?? "", - imageBuilder: (context, imageProvider) => GestureDetector( - onTap: () async { - Get.to( - () => ImageViewScreen(imageProvider: imageProvider), - routeName: Routes.imageView, - ); - }, - child: Container( - height: double.infinity, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - image: DecorationImage( - image: imageProvider, - fit: BoxFit.cover, - ), - ), - ), - ), - placeholder: (context, url) => Container( - height: double.infinity, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Colors.grey[300], - ), - child: Center( - child: Icon(Icons.photo), - ), - ), - errorWidget: (context, url, error) => Container( - height: double.infinity, - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: Colors.grey[300], - ), - child: Center( - child: Icon(Icons.photo), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/custom_alert_dialog.dart b/lib/widgets/dialogs/custom_alert_dialog.dart similarity index 100% rename from lib/widgets/custom_alert_dialog.dart rename to lib/widgets/dialogs/custom_alert_dialog.dart diff --git a/lib/widgets/dialogs/error_dialog.dart b/lib/widgets/dialogs/error_dialog.dart new file mode 100644 index 0000000..c3f378b --- /dev/null +++ b/lib/widgets/dialogs/error_dialog.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +final _formKey = GlobalKey(); + +class ErrorDialog extends StatelessWidget { + final Widget? contenido; + final Widget mensaje; + + const ErrorDialog({ + this.contenido, + required this.mensaje, + }); + + @override + Widget build(BuildContext context) => Dialog( + elevation: 0, + backgroundColor: Colors.transparent, + child: Container( + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + ), + child: Padding( + padding: EdgeInsets.all(30), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Container(child: this.mensaje), + if (this.contenido != null) Padding( + padding: EdgeInsets.all(20), + child: this.contenido, + ), + Padding( + padding: EdgeInsets.only(top: 20), + child: TextButton( + onPressed: () => Navigator.pop(context), + child: Text("Entendido 😥"), + ), + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/dialogs/monkey_error_dialog.dart b/lib/widgets/dialogs/monkey_error_dialog.dart index 0108194..d00f2c4 100644 --- a/lib/widgets/dialogs/monkey_error_dialog.dart +++ b/lib/widgets/dialogs/monkey_error_dialog.dart @@ -1,29 +1,27 @@ import 'package:flare_flutter/flare_actor.dart'; import 'package:flutter/material.dart'; -import 'package:mi_utem/widgets/error_dialog.dart'; +import 'package:mi_utem/widgets/dialogs/error_dialog.dart'; class MonkeyErrorDialog extends StatelessWidget { const MonkeyErrorDialog({ - Key? key, - }) : super(key: key); + super.key, + }); @override - Widget build(BuildContext context) { - return ErrorDialog( - contenido: Container( - height: 100, - child: FlareActor( - "assets/animations/monito.flr", - alignment: Alignment.center, - fit: BoxFit.contain, - animation: "rascarse", - ), + Widget build(BuildContext context) => ErrorDialog( + contenido: Container( + height: 100, + child: FlareActor( + "assets/animations/monito.flr", + alignment: Alignment.center, + fit: BoxFit.contain, + animation: "rascarse", ), - mensaje: Text( - "Ops, parece que metimos la pata. Sólo queda esperar e intentarlo más tarde.", - textAlign: TextAlign.center, - style: TextStyle(fontSize: 20), - ), - ); - } + ), + mensaje: Text( + "Ops, parece que metimos la pata. Sólo queda esperar e intentarlo más tarde.", + textAlign: TextAlign.center, + style: TextStyle(fontSize: 20), + ), + ); } diff --git a/lib/widgets/dialogs/not_ready_dialog.dart b/lib/widgets/dialogs/not_ready_dialog.dart index f0818e5..aa08c18 100644 --- a/lib/widgets/dialogs/not_ready_dialog.dart +++ b/lib/widgets/dialogs/not_ready_dialog.dart @@ -1,89 +1,65 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/config/routes.dart'; +import 'package:mi_utem/screens/acerca_screen.dart'; import 'package:mi_utem/themes/theme.dart'; -import 'package:mi_utem/widgets/error_dialog.dart'; +import 'package:mi_utem/widgets/dialogs/error_dialog.dart'; import 'package:url_launcher/url_launcher.dart'; class NotReadyDialog extends StatelessWidget { const NotReadyDialog({ - Key? key, - }) : super(key: key); + super.key, + }); @override - Widget build(BuildContext context) { - return ErrorDialog( - mensaje: RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: TextStyle( - color: Colors.grey[800], - fontSize: 16, - ), - children: [ - TextSpan( - text: "Aún no estás listo para esto", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - color: MainTheme.primaryDarkColor, - ), + Widget build(BuildContext context) => ErrorDialog( + mensaje: RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: TextStyle( + color: Colors.grey[800], + fontSize: 16, + ), + children: [ + TextSpan( + text: "Aún no estás listo para esto", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: MainTheme.primaryDarkColor, ), - TextSpan( - text: "\n\n👀\n\n", - style: TextStyle( - fontSize: 80, - ), + ), + const TextSpan( + text: "\n\n\u{1F440}\n\n", + style: TextStyle( + fontSize: 80, ), - TextSpan( - text: - "• Si eres estudiante nuevo, debes esperar a que se habilite la plataforma para ti. \n• Si eres estudiante antiguo y tampoco puedes acceder a la plataforma web "), - TextSpan( - text: "mi.utem.cl", - style: TextStyle( - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - launchUrl( - Uri.parse( - "https://mi.utem.cl/?ref=appMiUtemInndev", - ), - ); - }, + ), + const TextSpan(text: "• Si eres estudiante nuevo, debes esperar a que se habilite la plataforma para ti. \n• Si eres estudiante antiguo y tampoco puedes acceder a la plataforma web "), + TextSpan( + text: "mi.utem.cl", + style: const TextStyle( + decoration: TextDecoration.underline, ), - TextSpan(text: ", contáctate con "), - TextSpan( - text: "soporte.sisei@utem.cl", - style: TextStyle( - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - launchUrl( - Uri.parse( - "mailto:soporte.sisei@utem.cl", - ), - ); - }, + recognizer: TapGestureRecognizer()..onTap = () => launchUrl(Uri.parse("https://mi.utem.cl/?ref=appMiUtemInndev")), + ), + const TextSpan(text: ", contáctate con "), + TextSpan( + text: "soporte.sisei@utem.cl", + style: const TextStyle( + decoration: TextDecoration.underline, ), - TextSpan( - text: - ", ellos podrán ayudarte. \n• Si el problema solo es la app, contáctanos a "), - TextSpan( - text: "nuestras redes sociales", - style: TextStyle( - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - Get.toNamed(Routes.about); - }, + recognizer: TapGestureRecognizer()..onTap = () => launchUrl(Uri.parse("mailto:soporte.sisei@utem.cl")), + ), + TextSpan(text: ", ellos podrán ayudarte. \n• Si el problema solo es la app, contáctanos a "), + TextSpan( + text: "nuestras redes sociales", + style: TextStyle( + decoration: TextDecoration.underline, ), - ], - ), + recognizer: TapGestureRecognizer()..onTap = () => Navigator.push(context, MaterialPageRoute(builder: (ctx) => AcercaScreen())), + ), + ], ), - ); - } + ), + ); } diff --git a/lib/widgets/sad_dialog.dart b/lib/widgets/dialogs/sad_dialog.dart similarity index 94% rename from lib/widgets/sad_dialog.dart rename to lib/widgets/dialogs/sad_dialog.dart index cf22aee..db3305d 100644 --- a/lib/widgets/sad_dialog.dart +++ b/lib/widgets/dialogs/sad_dialog.dart @@ -1,7 +1,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/config/routes.dart'; +import 'package:mi_utem/screens/acerca_screen.dart'; final _formKey = GlobalKey(); @@ -97,11 +96,7 @@ class SadDialog extends StatelessWidget { Padding( padding: EdgeInsets.only(top: 20), child: TextButton( - onPressed: () async { - Get.toNamed( - Routes.about, - ); - }, + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (ctx) => AcercaScreen())), child: Text("Quiero saber más"), ), ) diff --git a/lib/widgets/saludo_dialog.dart b/lib/widgets/dialogs/saludo_dialog.dart similarity index 100% rename from lib/widgets/saludo_dialog.dart rename to lib/widgets/dialogs/saludo_dialog.dart diff --git a/lib/widgets/error_dialog.dart b/lib/widgets/error_dialog.dart deleted file mode 100644 index 7c7331d..0000000 --- a/lib/widgets/error_dialog.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:get/get.dart'; - -final _formKey = GlobalKey(); - -class ErrorDialog extends StatelessWidget { - final Widget? contenido; - final Widget mensaje; - ErrorDialog({ - this.contenido, - required this.mensaje, - }); - - @override - Widget build(BuildContext context) { - return Dialog( - elevation: 0, - backgroundColor: Colors.transparent, - child: Container( - child: Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15.0), - ), - child: Padding( - padding: EdgeInsets.all(30), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Container(child: this.mensaje), - if (this.contenido != null) - Padding( - padding: EdgeInsets.all(20), - child: this.contenido), - Padding( - padding: EdgeInsets.only(top: 20), - child: TextButton( - onPressed: () async { - Get.back(); - }, - child: Text("Entendido 😥"), - )) - ])))))); - } -} diff --git a/lib/widgets/field_list_tile.dart b/lib/widgets/field_list_tile.dart index 6074ca2..5c8e92b 100644 --- a/lib/widgets/field_list_tile.dart +++ b/lib/widgets/field_list_tile.dart @@ -1,47 +1,54 @@ import 'package:flutter/material.dart'; -import 'package:get/get.dart'; class FieldListTile extends StatelessWidget { + final String title; + final String? value; + final EdgeInsetsGeometry padding; + final Widget? suffixIcon; + final Function()? onTap; + const FieldListTile({ - Key? key, + super.key, required this.title, this.value, this.padding = const EdgeInsets.symmetric( vertical: 12, horizontal: 16, ), - }) : super(key: key); - - final String title; - final String? value; - final EdgeInsetsGeometry padding; + this.suffixIcon, + this.onTap, + }); @override - Widget build(BuildContext context) { - return Padding( - padding: padding, + Widget build(BuildContext context) => Padding( + padding: padding, + child: GestureDetector( + onTap: onTap, + behavior: onTap != null ? HitTestBehavior.opaque : HitTestBehavior.deferToChild, child: Row( children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - title.toUpperCase(), - maxLines: 2, - style: Get.textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - value ?? "Sin información", - style: Get.textTheme.bodyMedium, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title.toUpperCase(), + maxLines: 2, + style: Theme.of(context).textTheme.bodySmall!.copyWith(fontWeight: FontWeight.bold), + ), + Text(value ?? "Sin información", + style: Theme.of(context).textTheme.bodyMedium, + ), + ], ), + if(suffixIcon != null) suffixIcon!, ], ), ), ], ), - ); - } + ), + ); } diff --git a/lib/widgets/footer_layout.dart b/lib/widgets/footer_layout.dart deleted file mode 100644 index 4f178fe..0000000 --- a/lib/widgets/footer_layout.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; - -import 'package:get/get.dart'; - -class FooterLayout extends StatelessWidget { - const FooterLayout({ - Key? key, - required this.body, - required this.footer, - }) : super(key: key); - - final Widget body; - final Widget footer; - - @override - Widget build(BuildContext context) { - return CustomMultiChildLayout( - delegate: _FooterLayoutDelegate(Get.mediaQuery.viewInsets), - children: [ - LayoutId( - id: _FooterLayout.body, - child: body, - ), - LayoutId( - id: _FooterLayout.footer, - child: footer, - ), - ], - ); - } -} - -enum _FooterLayout { - footer, - body, -} - -class _FooterLayoutDelegate extends MultiChildLayoutDelegate { - final EdgeInsets viewInsets; - - _FooterLayoutDelegate(this.viewInsets); - - @override - void performLayout(Size size) { - size = Size(size.width, size.height + viewInsets.bottom); - final footer = - layoutChild(_FooterLayout.footer, BoxConstraints.loose(size)); - - final bodyConstraints = BoxConstraints.tightFor( - height: size.height - max(footer.height, viewInsets.bottom), - width: size.width, - ); - - final body = layoutChild(_FooterLayout.body, bodyConstraints); - - positionChild(_FooterLayout.body, Offset.zero); - positionChild(_FooterLayout.footer, Offset(0, body.height)); - } - - @override - bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) { - return true; - } -} diff --git a/lib/widgets/gradient_background.dart b/lib/widgets/gradient_background.dart new file mode 100644 index 0000000..72cb7c1 --- /dev/null +++ b/lib/widgets/gradient_background.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/themes/theme.dart'; + +class GradientBackground extends StatelessWidget { + final Widget child; + final bool resizeToAvoidBottomInset; + + const GradientBackground({ + super.key, + required this.child, + this.resizeToAvoidBottomInset = true, + }); + + @override + Widget build(BuildContext context) => Scaffold( + resizeToAvoidBottomInset: resizeToAvoidBottomInset, + body: Stack( + children: [ + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomLeft, + end: Alignment.topRight, + colors: [MainTheme.utemAzul, MainTheme.utemVerde], + ), + ), + ), + SafeArea(child: child), + ], + ), + ); +} \ No newline at end of file diff --git a/lib/widgets/horario/bloque_clase.dart b/lib/widgets/horario/bloque_clase.dart new file mode 100644 index 0000000..85897d7 --- /dev/null +++ b/lib/widgets/horario/bloque_clase.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/controllers/horario_controller.dart'; +import 'package:mi_utem/models/horario.dart'; +import 'package:mi_utem/themes/theme.dart'; + +class BloqueClase extends StatelessWidget { + final BloqueHorario block; + final double width; + final double height; + final Color textColor; + final Color? color; + final void Function(BloqueHorario)? onTap; + final void Function(BloqueHorario)? onLongPress; + + const BloqueClase({ + super.key, + required this.block, + required this.width, + required this.height, + required this.textColor, + this.color = Colors.teal, + this.onTap, + this.onLongPress, + }); + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration( + color: Get.find().getColor(block.asignatura) ?? this.color, + borderRadius: BorderRadius.circular(15), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(15), + onTap: () => onTap?.call(block), + onLongPress: () => onLongPress?.call(block), + child: Column( + children: [ + HorarioText.classCode("${block.codigo}", + color: textColor, + ), + HorarioText.className("${block.asignatura?.nombre.toUpperCase()}", + color: textColor, + ), + HorarioText.classLocation(block.sala ?? "Sin sala", + color: textColor, + ), + ], + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + ), + ), + ), + ); +} \ No newline at end of file diff --git a/lib/widgets/bloque_dias_card.dart b/lib/widgets/horario/bloque_dias_card.dart similarity index 93% rename from lib/widgets/bloque_dias_card.dart rename to lib/widgets/horario/bloque_dias_card.dart index 2ab1ded..5ad9d20 100644 --- a/lib/widgets/bloque_dias_card.dart +++ b/lib/widgets/horario/bloque_dias_card.dart @@ -27,9 +27,8 @@ class BloqueDiasCard extends StatelessWidget { height: height, width: width, child: Column( - children: [ - Text( - day.trim(), + children: [ + Text(day.trim(), textAlign: TextAlign.center, style: TextStyle( color: Color(0xFF363636), diff --git a/lib/widgets/horario/bloque_periodo_card.dart b/lib/widgets/horario/bloque_periodo_card.dart new file mode 100644 index 0000000..642d3ad --- /dev/null +++ b/lib/widgets/horario/bloque_periodo_card.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/themes/theme.dart'; + +class BloquePeriodoCard extends StatelessWidget { + final String? inicio; + final String? intermedio; + final String? fin; + final double height; + final double width; + final bool active; + final Color backgroundColor; + + const BloquePeriodoCard({ + super.key, + required this.inicio, + required this.intermedio, + required this.fin, + required this.height, + required this.width, + this.backgroundColor = MainTheme.lightGrey, + this.active = false, + }); + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration(color: backgroundColor), + height: height, + width: width, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(inicio!, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black54, + fontSize: 18, + fontWeight: active ? FontWeight.bold : FontWeight.normal, + ), + ), + Text(intermedio!, + maxLines: 3, + textAlign: TextAlign.center, + style: TextStyle( + letterSpacing: 0.5, + wordSpacing: 1, + color: Colors.black54, + fontSize: 14, + fontWeight: active ? FontWeight.bold : FontWeight.normal, + ), + ), + Text(fin!, + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black54, + fontSize: 18, + fontWeight: active ? FontWeight.bold : FontWeight.normal, + ), + ) + ], + ), + ); +} diff --git a/lib/widgets/horario/bloque_ramo_card.dart b/lib/widgets/horario/bloque_ramo_card.dart new file mode 100644 index 0000000..4df4640 --- /dev/null +++ b/lib/widgets/horario/bloque_ramo_card.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/models/horario.dart'; +import 'package:mi_utem/repositories/asignaturas_repository.dart'; +import 'package:mi_utem/repositories/grades_repository.dart'; +import 'package:mi_utem/screens/asignatura/detalle/asignatura_detalle_screen.dart'; +import 'package:mi_utem/services/analytics_service.dart'; +import 'package:mi_utem/services/carreras_service.dart'; +import 'package:mi_utem/widgets/horario/bloque_clase.dart'; +import 'package:mi_utem/widgets/horario/bloque_vacio.dart'; +import 'package:mi_utem/widgets/horario/modals/asignatura_vista_previa_modal.dart'; +import 'package:mi_utem/widgets/loading/loading_dialog.dart'; + +class ClassBlockCard extends StatelessWidget { + final BloqueHorario? block; + final double width; + final double height; + final double internalMargin; + final Color textColor; + + const ClassBlockCard({ + super.key, + required this.block, + required this.width, + required this.height, + this.internalMargin = 0, + this.textColor = Colors.white, + }); + + @override + Widget build(BuildContext context) => SizedBox( + height: height, + width: width, + child: Padding( + padding: EdgeInsets.all(internalMargin), + child: block?.asignatura == null ? BloqueVacio() : BloqueClase( + block: block!, + width: width, + height: height, + textColor: textColor, + onTap: (block) => _onTap(block, context), + onLongPress: (block) => _onLongPress(block, context), + ), + ), + ); + + _onTap(BloqueHorario block, BuildContext context) async { + showLoadingDialog(context); + final carrera = await Get.find().getCarreras(); + final asignatura = (await Get.find().getAsignaturas(carrera?.id))?.firstWhereOrNull((asignatura) => asignatura.id == block.asignatura?.id || asignatura.codigo == block.asignatura?.codigo); + final grades = await Get.find().getGrades(carreraId: carrera?.id, asignaturaId: asignatura?.id); + if(carrera == null || asignatura == null) { + Navigator.pop(context); + return; + } + + AnalyticsService.logEvent("horario_class_block_tap", parameters: { + "asignatura": asignatura.nombre, + "codigo": asignatura.codigo, + }); + Navigator.pop(context); + Navigator.push(context, MaterialPageRoute(builder: (ctx) => AsignaturaDetalleScreen( + carrera: carrera, + asignatura: asignatura.copyWith(grades: grades), + ))); + } + + _onLongPress(BloqueHorario block, BuildContext context) async { + showLoadingDialog(context); + final carrera = await Get.find().getCarreras(); + final asignatura = (await Get.find().getAsignaturas(carrera?.id))?.firstWhereOrNull((asignatura) => asignatura.id == block.asignatura?.id || asignatura.codigo == block.asignatura?.codigo); + if(carrera == null || asignatura == null) { + Navigator.pop(context); + return; + } + + AnalyticsService.logEvent("horario_class_block_long_press", parameters: { + "asignatura": block.asignatura?.nombre, + "codigo": block.asignatura?.codigo, + }); + Navigator.pop(context); + showModalBottomSheet(context: context, builder: (ctx) => AsignaturaVistaPreviaModal(asignatura: asignatura, bloque: block), shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20)))); + } +} diff --git a/lib/widgets/horario/bloque_vacio.dart b/lib/widgets/horario/bloque_vacio.dart new file mode 100644 index 0000000..b788adb --- /dev/null +++ b/lib/widgets/horario/bloque_vacio.dart @@ -0,0 +1,24 @@ +import 'package:dotted_border/dotted_border.dart'; +import 'package:flutter/material.dart'; +import 'package:mi_utem/themes/theme.dart'; + +class BloqueVacio extends StatelessWidget { + const BloqueVacio({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: MainTheme.lightGrey, + borderRadius: BorderRadius.circular(15), + ), + child: DottedBorder( + strokeWidth: 2, + color: MainTheme.grey, + borderType: BorderType.RRect, + radius: Radius.circular(15), + child: Container(), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/horario/modals/asignatura_vista_previa_modal.dart b/lib/widgets/horario/modals/asignatura_vista_previa_modal.dart new file mode 100644 index 0000000..191cd16 --- /dev/null +++ b/lib/widgets/horario/modals/asignatura_vista_previa_modal.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/asignaturas/asignatura.dart'; +import 'package:mi_utem/models/horario.dart'; +import 'package:mi_utem/widgets/modals/persona_modal.dart'; + +class AsignaturaVistaPreviaModal extends StatelessWidget { + final Asignatura asignatura; + final BloqueHorario bloque; + + const AsignaturaVistaPreviaModal({ + super.key, + required this.asignatura, + required this.bloque, + }); + + @override + Widget build(BuildContext context) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Container( + alignment: Alignment.topCenter, + child: Card( + margin: const EdgeInsets.all(20), + child: ListView( + padding: EdgeInsets.only(top: 5.0, bottom: 5.0), + shrinkWrap: true, + physics: ScrollPhysics(), + children: [ + GestureDetector( + child: ListTile( + title: Text(asignatura.nombre), + subtitle: Text(asignatura.docente.nombreCompleto), // Se filtran enteros, al parecer hay algunos textos que incluyen un identificador. + ), + onTap: () async => showModalBottomSheet(context: context, builder: (ctx) => PersonaModal(persona: asignatura.docente)), + ), + if (asignatura.seccion.isNotEmpty) ...[ + Divider(height: 5, indent: 20, endIndent: 20), + ListTile( + title: Text("Sección"), + subtitle: Text(asignatura.seccion.toString()), + ), + ], + Divider(height: 5, indent: 20, endIndent: 20), + ListTile( + title: Text("Código Asignatura"), + subtitle: Text(asignatura.codigo.toString()), + ), + Divider(height: 5, indent: 20, endIndent: 20), + ListTile( + title: Text("Sala"), + subtitle: Text(bloque.sala ?? "Sin sala"), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/widgets/horario/ticker_time_text.dart b/lib/widgets/horario/ticker_time_text.dart new file mode 100644 index 0000000..8099d7a --- /dev/null +++ b/lib/widgets/horario/ticker_time_text.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class TickerTimeText extends StatefulWidget { + final DateTime time; + + const TickerTimeText({ + super.key, + required this.time, + }); + + @override + State createState() => __TickerTimeTextState(); +} + +class __TickerTimeTextState extends State { + Timer? _timer; + bool _showColon = true; + + @override + void initState() { + _timer = Timer.periodic(Duration(seconds: 1), (Timer t) => setState(() => _showColon = !_showColon)); + super.initState(); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + + @override + Widget build(BuildContext context) => RichText( + overflow: TextOverflow.fade, + maxLines: 1, + text: TextSpan( + children: [ + TextSpan( + text: "${widget.time.hour}", + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white, + ), + ), + TextSpan( + text: ":", + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: _showColon ? Colors.white : Colors.transparent, + ), + ), + TextSpan( + text: "${widget.time.minute}", + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.white, + ), + ), + ], + ), + ); +} \ No newline at end of file diff --git a/lib/widgets/image/default_network_image.dart b/lib/widgets/image/default_network_image.dart new file mode 100644 index 0000000..859347a --- /dev/null +++ b/lib/widgets/image/default_network_image.dart @@ -0,0 +1,56 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:mi_utem/widgets/image/image_view_screen.dart'; + +class DefaultNetworkImage extends StatelessWidget { + final String? url; + + const DefaultNetworkImage({ + this.url, + }); + + @override + Widget build(BuildContext context) => Container( + height: double.infinity, + width: double.infinity, + child: CachedNetworkImage( + imageUrl: "$url", + imageBuilder: (context, imageProvider) => GestureDetector( + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (ctx) => ImageViewScreen(imageProvider: imageProvider))), + child: Container( + height: double.infinity, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + image: DecorationImage( + image: imageProvider, + fit: BoxFit.cover, + ), + ), + ), + ), + placeholder: (context, url) => Container( + height: double.infinity, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Colors.grey[300], + ), + child: Center( + child: Icon(Icons.photo), + ), + ), + errorWidget: (context, url, error) => Container( + height: double.infinity, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Colors.grey[300], + ), + child: Center( + child: Icon(Icons.photo), + ), + ), + ), + ); +} diff --git a/lib/widgets/image_view_screen.dart b/lib/widgets/image/image_view_screen.dart similarity index 76% rename from lib/widgets/image_view_screen.dart rename to lib/widgets/image/image_view_screen.dart index a3065c7..b479298 100644 --- a/lib/widgets/image_view_screen.dart +++ b/lib/widgets/image/image_view_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_uxcam/flutter_uxcam.dart'; -import 'package:get/get.dart'; import 'package:photo_view/photo_view.dart'; class ImageViewScreen extends StatefulWidget { @@ -24,20 +23,14 @@ class _ImageViewScreenState extends State { Widget build(BuildContext context) { final photoView = PhotoView( imageProvider: widget.imageProvider, - heroAttributes: widget.heroTag != null - ? PhotoViewHeroAttributes(tag: widget.heroTag!) - : null, + heroAttributes: widget.heroTag != null ? PhotoViewHeroAttributes(tag: widget.heroTag!) : null, ); return Scaffold( body: Stack( - children: [ + children: [ Container( - child: widget.occlude - ? OccludeWrapper( - child: photoView, - ) - : photoView, + child: widget.occlude ? OccludeWrapper(child: photoView) : photoView, ), SafeArea( child: Padding( @@ -52,9 +45,7 @@ class _ImageViewScreenState extends State { Icons.arrow_back, size: 20, ), - onPressed: () { - Get.back(); - }, + onPressed: () => Navigator.pop(context), color: Colors.white, ), ), diff --git a/lib/widgets/imagen_editor_modal.dart b/lib/widgets/image/imagen_editor_modal.dart similarity index 98% rename from lib/widgets/imagen_editor_modal.dart rename to lib/widgets/image/imagen_editor_modal.dart index fc0ea49..43d52fe 100644 --- a/lib/widgets/imagen_editor_modal.dart +++ b/lib/widgets/image/imagen_editor_modal.dart @@ -1,10 +1,8 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:flutter/material.dart'; - import 'package:extended_image/extended_image.dart'; -import 'package:get/get.dart'; +import 'package:flutter/material.dart'; import 'package:image_editor/image_editor.dart'; class ImagenEditorModal extends StatefulWidget { @@ -51,7 +49,7 @@ class _ImagenEditorModalState extends State { extendBody: true, extendBodyBehindAppBar: true, bottomNavigationBar: new Theme( - data: Get.theme.copyWith( + data: Theme.of(context).copyWith( canvasColor: Colors.black.withOpacity(0.5), ), child: Container( @@ -108,7 +106,7 @@ class _ImagenEditorModalState extends State { break; default: Uint8List? imagen = await _cropImage(); - Get.back(result: imagen); + Navigator.pop(context, imagen); } }, ), diff --git a/lib/widgets/loading/loading_dialog.dart b/lib/widgets/loading/loading_dialog.dart new file mode 100644 index 0000000..803f1db --- /dev/null +++ b/lib/widgets/loading/loading_dialog.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/widgets/loading/loading_indicator.dart'; + +class LoadingDialog extends StatelessWidget { + @override + Widget build(BuildContext context) => WillPopScope( + onWillPop: () async => false, + child: LoadingIndicator( + color: Colors.white, + ) + ); +} + +void showLoadingDialog(BuildContext context) => showDialog(context: context, builder: (ctx) => LoadingDialog(), barrierDismissible: false); \ No newline at end of file diff --git a/lib/widgets/loading/loading_indicator.dart b/lib/widgets/loading/loading_indicator.dart new file mode 100644 index 0000000..992b50e --- /dev/null +++ b/lib/widgets/loading/loading_indicator.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; + +class LoadingIndicator extends StatelessWidget { + final Color color; + final AnimationController? controller; + final EdgeInsetsGeometry padding; + final String? message; + + const LoadingIndicator({ + this.color = const Color(0xFF009d9b), + this.controller, + this.padding = const EdgeInsets.all(20), + this.message, + }); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: Center( + child: Padding( + padding: padding, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SpinKitDoubleBounce( + controller: controller, + color: color, + size: 40.0, + ), + if (message != null) const SizedBox(height: 10), + if (message != null) Text("$message"), + ], + ), + ), + ), + ); + + static Widget centered({String? message}) => Center(child: LoadingIndicator(message: message)); + + static Widget centeredDefault() => centered(message: "Esto tardará un poco, paciencia..."); + +} diff --git a/lib/widgets/progress_button.dart b/lib/widgets/loading/progress_button.dart similarity index 70% rename from lib/widgets/progress_button.dart rename to lib/widgets/loading/progress_button.dart index ca2098c..a21bef3 100644 --- a/lib/widgets/progress_button.dart +++ b/lib/widgets/loading/progress_button.dart @@ -2,8 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:get/get.dart'; - class ProgressButton extends StatefulWidget { final Function callback; @@ -34,32 +32,30 @@ class _ProgressButtonState extends State } @override - Widget build(BuildContext context) { - return PhysicalModel( - color: Get.theme.primaryColor, - borderRadius: BorderRadius.circular(30), - child: Container( - key: _globalKey, - height: 48.0, - width: _width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: - _state == 2 ? Colors.green : Get.theme.primaryColor, - padding: EdgeInsets.zero, - ), - child: buildButtonChild(), - onPressed: () {}, - onFocusChange: (isPressed) { - setState(() { - if (_state == 0) { - animateButton(); - } - }); - }, - ), - )); - } + Widget build(BuildContext context) => PhysicalModel( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(30), + child: Container( + key: _globalKey, + height: 48.0, + width: _width, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: _state == 2 ? Colors.green : Theme.of(context).primaryColor, + padding: EdgeInsets.zero, + ), + child: buildButtonChild(), + onPressed: () {}, + onFocusChange: (isPressed) { + setState(() { + if (_state == 0) { + animateButton(); + } + }); + }, + ), + ), + ); void animateButton() { double initialWidth = _globalKey.currentContext!.size!.width; diff --git a/lib/widgets/loading_dialog.dart b/lib/widgets/loading_dialog.dart deleted file mode 100644 index 85231cc..0000000 --- a/lib/widgets/loading_dialog.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:mi_utem/widgets/loading_indicator.dart'; - -class LoadingDialog extends StatelessWidget { - @override - Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async => false, - child: LoadingIndicator( - color: Colors.white, - ) - ); - } -} diff --git a/lib/widgets/loading_indicator.dart b/lib/widgets/loading_indicator.dart deleted file mode 100644 index 65cefb4..0000000 --- a/lib/widgets/loading_indicator.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_spinkit/flutter_spinkit.dart'; - -class LoadingIndicator extends StatelessWidget { - final Color color; - final AnimationController? controller; - final EdgeInsetsGeometry padding; - final String? message; - - LoadingIndicator({ - this.color = const Color(0xFF009d9b), - this.controller, - this.padding = const EdgeInsets.all(20), - this.message, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 30), - child: Center( - child: Container( - padding: padding, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SpinKitDoubleBounce( - controller: controller, - color: color, - size: 40.0, - ), - if (message != null) Container(height: 10), - if (message != null) Text(message!), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/login_screen/_background.dart b/lib/widgets/login_screen/background.dart similarity index 64% rename from lib/screens/login_screen/_background.dart rename to lib/widgets/login_screen/background.dart index 26c0b5f..4e7934a 100644 --- a/lib/screens/login_screen/_background.dart +++ b/lib/widgets/login_screen/background.dart @@ -1,32 +1,31 @@ -part of 'login_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; -class _Background extends StatefulWidget { +class LoginBackground extends StatefulWidget { final Widget child; - const _Background({ - Key? key, + const LoginBackground({ + super.key, required this.child, - }) : super(key: key); + }); @override - _BackgroundState createState() => _BackgroundState(); + _LoginBackgroundState createState() => _LoginBackgroundState(); } -class _BackgroundState extends State<_Background> { +class _LoginBackgroundState extends State { late final VideoPlayerController _controller; @override void initState() { - _controller = VideoPlayerController.asset( - 'assets/videos/login_bg.mp4', - videoPlayerOptions: VideoPlayerOptions( - mixWithOthers: true, - ), - ) - ..setVolume(0) + _controller = VideoPlayerController.asset('assets/videos/login_bg.mp4', + videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), + )..setVolume(0) ..play() ..setLooping(true) - ..initialize(); + ..initialize().then((value) { + setState(() {}); // Esto debido a que hay veces que no inicia el video a menos que se haga un hot-reload + }); super.initState(); } @@ -49,7 +48,7 @@ class _BackgroundState extends State<_Background> { return Stack( alignment: Alignment.center, - children: [ + children: [ Container( decoration: backgroundDecoration, ), @@ -70,7 +69,7 @@ class _BackgroundState extends State<_Background> { color: Color(0x80000000), ), Container( - height: Get.height, + height: double.infinity, child: widget.child, ), ], diff --git a/lib/widgets/login_screen/creditos_app.dart b/lib/widgets/login_screen/creditos_app.dart new file mode 100644 index 0000000..c5a38e3 --- /dev/null +++ b/lib/widgets/login_screen/creditos_app.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:mi_utem/screens/acerca_screen.dart'; +import 'package:mi_utem/services/remote_config/remote_config.dart'; + +class CreditosApp extends StatelessWidget { + + const CreditosApp({ + super.key + }); + + get _creditText { + final texts = jsonDecode(RemoteConfigService.creditos) as List; + return texts[Random().nextInt(texts.length)]; + } + + @override + Widget build(BuildContext context) => Expanded( + child: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.all(10), + child: SafeArea( + child: GestureDetector( + child: MarkdownBody( + selectable: false, + styleSheet: MarkdownStyleSheet( + textAlign: WrapAlignment.center, + p: TextStyle(color: Colors.white), + ), + data: _creditText, + ), + onTap: () => Navigator.push(context, MaterialPageRoute( + builder: (context) => AcercaScreen(), + fullscreenDialog: true, + )), + ), + ), + ), + ], + ), + ), + ); +} \ No newline at end of file diff --git a/lib/widgets/login_screen/formulario_credenciales.dart b/lib/widgets/login_screen/formulario_credenciales.dart new file mode 100644 index 0000000..abbe2f6 --- /dev/null +++ b/lib/widgets/login_screen/formulario_credenciales.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mi_utem/widgets/login_screen/login_text_form_field.dart'; + +class FormularioCredenciales extends StatefulWidget { + + final TextEditingController correoController; + final TextEditingController contraseniaController; + + const FormularioCredenciales({ + super.key, + required this.correoController, + required this.contraseniaController, + }); + + @override + State createState() => _FormularioCredencialesState(); +} + +class _FormularioCredencialesState extends State { + + @override + Widget build(BuildContext context) => AutofillGroup( + onDisposeAction: AutofillContextAction.commit, + child: Column( + children: [ + LoginTextFormField( + controller: widget.correoController, + hintText: 'usuario@utem.cl', + labelText: 'Usuario/Correo UTEM', + textCapitalization: TextCapitalization.none, + keyboardType: TextInputType.emailAddress, + inputFormatters: [ + FilteringTextInputFormatter.deny(RegExp(" ")), + ], + icon: Icons.person, + autofillHints: [AutofillHints.email, AutofillHints.username], + validator: (String value) { + if (value.isEmpty) { + return 'Debe ingresar un correo UTEM'; + } else if (value.contains("@") && !value.endsWith("@utem.cl")) { + return 'Debe ingresar un correo UTEM'; + } + }, + ), + LoginTextFormField( + controller: widget.contraseniaController, + hintText: '• • • • • • • • •', + labelText: 'Contraseña', + textCapitalization: TextCapitalization.none, + icon: Icons.lock, + obscureText: true, + autofillHints: [AutofillHints.password], + validator: (String value) { + if (value.isEmpty) { + return 'Debe ingresar una contraseña'; + } + }, + ) + ], + ), + ); +} \ No newline at end of file diff --git a/lib/widgets/login_screen/login_button.dart b/lib/widgets/login_screen/login_button.dart new file mode 100644 index 0000000..1e5bb3b --- /dev/null +++ b/lib/widgets/login_screen/login_button.dart @@ -0,0 +1,133 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/config/logger.dart'; +import 'package:mi_utem/models/exceptions/custom_exception.dart'; +import 'package:mi_utem/models/preferencia.dart'; +import 'package:mi_utem/models/user/credential.dart'; +import 'package:mi_utem/repositories/credentials_repository.dart'; +import 'package:mi_utem/screens/main_screen.dart'; +import 'package:mi_utem/screens/onboarding/welcome_screen.dart'; +import 'package:mi_utem/services/analytics_service.dart'; +import 'package:mi_utem/services/auth_service.dart'; +import 'package:mi_utem/widgets/acerca/dialog/acerca_dialog.dart'; +import 'package:mi_utem/widgets/dialogs/monkey_error_dialog.dart'; +import 'package:mi_utem/widgets/loading/loading_dialog.dart'; +import 'package:mi_utem/widgets/snackbar.dart'; + + +class LoginButton extends StatefulWidget { + + final TextEditingController correoController; + final TextEditingController contraseniaController; + + final GlobalKey formKey; + + LoginButton({ + super.key, + required this.correoController, + required this.contraseniaController, + required this.formKey, + }); + + @override + _LoginButtonState createState() => _LoginButtonState(); + +} + +class _LoginButtonState extends State { + + final _authService = Get.find(); + final _credentialsService = Get.find(); + + @override + Widget build(BuildContext context) => TextButton( + onPressed: () => _login(context), + child: Text("Iniciar Sesión"), + ); + + Future _login(BuildContext context) async { + final correo = widget.correoController.text; + final contrasenia = widget.contraseniaController.text; + + if (correo == "error@utem.cl") { + showDialog(context: context, builder: (ctx) => MonkeyErrorDialog()); + return; + } else if (correo == "test@utem.cl" && contrasenia == "test") { + showTextSnackbar(context, + title: "Error", + message: "Usuario o contraseña incorrecta", + ); + return; + } + + if(widget.formKey.currentState?.validate() == false) { + return; + } + + showLoadingDialog(context); + + try { + await _credentialsService.setCredentials(Credentials( + email: correo, + password: contrasenia, + )); + + if(!(await _credentialsService.hasCredentials())) { + showTextSnackbar(context, + title: "Error", + message: "Ha ocurrido un error al guardar tus claves. Intenta más tarde.", + ); + return; + } + + await _authService.login(); + + final isFirstTime = await _authService.isFirstTime(); + final user = await _authService.getUser(); + if(user == null) { + Navigator.pop(context); + showTextSnackbar(context, + title: "Error", + message: "Ha ocurrido un error desconocido. Por favor intenta más tarde.", + ); + return; + } + + AnalyticsService.logEvent('login'); + + Navigator.of(context).popUntil((route) => route.isFirst); // Esto elimina todas las pantallas anteriores + // Y esto reemplaza la pantalla actual por la nueva, cosa de que no pueda "volver" al login a menos que cierre la sesión. + final hasCompletedOnboarding = (await Preferencia.onboardingStep.get()) == 'complete'; + if(hasCompletedOnboarding) { + Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => MainScreen())); + if(isFirstTime) { + showDialog(context: context, builder: (ctx) => AcercaDialog()); + } + return; + } + + Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) => WelcomeScreen())); + } on CustomException catch (e) { + logger.e(e); + Navigator.pop(context); + showTextSnackbar(context, + title: "Error", + message: e.message, + ); + } on DioError catch (e) { + Navigator.pop(context); + showTextSnackbar(context, + title: "Error", + message: (e.error as CustomException).message, + ); + } catch (e) { + logger.e(e); + Navigator.pop(context); + showTextSnackbar(context, + title: "Error", + message: "Ha ocurrido un error desconocido. Por favor intenta más tarde.", + ); + } + } +} \ No newline at end of file diff --git a/lib/widgets/login_screen/login_form.dart b/lib/widgets/login_screen/login_form.dart new file mode 100644 index 0000000..b352e3f --- /dev/null +++ b/lib/widgets/login_screen/login_form.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/repositories/credentials_repository.dart'; +import 'package:mi_utem/services/update_service.dart'; +import 'package:mi_utem/widgets/login_screen/creditos_app.dart'; +import 'package:mi_utem/widgets/login_screen/formulario_credenciales.dart'; +import 'package:mi_utem/widgets/login_screen/login_button.dart'; + +class LoginForm extends StatefulWidget { + final BoxConstraints constraints; + + const LoginForm({ + super.key, + required this.constraints, + }); + + @override + State createState() => _LoginFormState(); +} + +class _LoginFormState extends State { + + final GlobalKey _formKey = GlobalKey(); + final TextEditingController _correoController = TextEditingController(); + final TextEditingController _contraseniaController = TextEditingController(); + + final _credentialService = Get.find(); + + @override + void initState() { + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarBrightness: Brightness.light, + statusBarIconBrightness: Brightness.light, + systemNavigationBarColor: Colors.black, + systemNavigationBarIconBrightness: Brightness.light, + )); + + UpdateService(); + + _credentialService.getCredentials().then((credential){ + if(credential == null) { + return; + } + + _correoController.text = credential.email; + _contraseniaController.text = credential.password; + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: ConstrainedBox( + constraints: widget.constraints.copyWith( + minHeight: widget.constraints.maxHeight, + maxHeight: double.infinity, + ), + child: IntrinsicHeight( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container(height: widget.constraints.maxHeight * 0.1), + Expanded( + child: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Hero( + tag: 'utemLogo', + child: Image.asset('assets/images/utem_logo_color_blanco.png', width: 250), + ), + ], + ), + ), + ), + Container(height: widget.constraints.maxHeight * 0.1), + FormularioCredenciales( + correoController: _correoController, + contraseniaController: _contraseniaController, + ), + LoginButton( + correoController: _correoController, + contraseniaController: _contraseniaController, + formKey: _formKey, + ), + Container(height: widget.constraints.maxHeight * 0.1), + const CreditosApp(), + ], + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/login_text_form_field.dart b/lib/widgets/login_screen/login_text_form_field.dart similarity index 85% rename from lib/widgets/login_text_form_field.dart rename to lib/widgets/login_screen/login_text_form_field.dart index 26e3230..339c86a 100644 --- a/lib/widgets/login_text_form_field.dart +++ b/lib/widgets/login_screen/login_text_form_field.dart @@ -2,7 +2,6 @@ import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:get/get.dart'; class LoginTextFormField extends StatefulWidget { LoginTextFormField({ @@ -64,24 +63,24 @@ class _LoginTextFormFieldState extends State { borderSide: BorderSide(color: Colors.white, width: 2)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(25), - borderSide: BorderSide(color: Get.theme.primaryColor, width: 2)), + borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2)), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(25), - borderSide: BorderSide(color: Get.theme.primaryColor, width: 2)), + borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2)), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(25), borderSide: BorderSide(color: Colors.red, width: 2)), contentPadding: EdgeInsets.symmetric(vertical: 12, horizontal: 20), labelStyle: TextStyle( color: _focusNode!.hasFocus - ? Get.theme.primaryColor - : (_error ? Get.theme.colorScheme.error : Color(0x80FFFFFF))), + ? Theme.of(context).primaryColor + : (_error ? Theme.of(context).colorScheme.error : Color(0x80FFFFFF))), errorStyle: TextStyle(color: Colors.red), hintStyle: TextStyle(color: Color(0x80FFFFFF)), prefixIcon: Icon(widget.icon, color: _focusNode!.hasFocus - ? Get.theme.primaryColor - : (_error ? Get.theme.colorScheme.error : Colors.white)), + ? Theme.of(context).primaryColor + : (_error ? Theme.of(context).colorScheme.error : Colors.white)), hintText: widget.hintText, labelText: widget.labelText, ), @@ -97,6 +96,9 @@ class _LoginTextFormFieldState extends State { } return errorMsg; }, + onTapOutside: (event){ + FocusScope.of(context).unfocus(); + }, ), ); } diff --git a/lib/widgets/main_screen/novedades/banner.dart b/lib/widgets/main_screen/novedades/banner.dart new file mode 100644 index 0000000..d9318cc --- /dev/null +++ b/lib/widgets/main_screen/novedades/banner.dart @@ -0,0 +1,42 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/novedades/ibanner.dart'; +import 'package:mi_utem/services/analytics_service.dart'; +import 'package:url_launcher/url_launcher.dart'; + + +class MiUtemBanner extends StatelessWidget { + final IBanner banner; + + const MiUtemBanner({ + super.key, + required this.banner, + }); + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => Container( + width: constraints.maxWidth, + height: constraints.maxWidth * 0.4, + child: InkWell( + onTap: _onTap, + child: Card( + margin: EdgeInsets.zero, + color: banner.backgroundColor, + borderOnForeground: false, + child: CachedNetworkImage( + imageUrl: banner.imageUrl, + fit: BoxFit.cover, + ), + ), + ), + ), + ); + + void _onTap() async { + AnalyticsService.logEvent("banner_tap", parameters: { + "banner_id": banner.id, + }); + await launchUrl(Uri.parse(banner.url)); + } +} diff --git a/lib/widgets/banners_section.dart b/lib/widgets/main_screen/novedades/banners_section.dart similarity index 73% rename from lib/widgets/banners_section.dart rename to lib/widgets/main_screen/novedades/banners_section.dart index 5266c86..0f71051 100644 --- a/lib/widgets/banners_section.dart +++ b/lib/widgets/main_screen/novedades/banners_section.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/widgets/banner.dart'; +import 'package:mi_utem/models/novedades/ibanner.dart'; +import 'package:mi_utem/widgets/main_screen/novedades/banner.dart'; class BannersSection extends StatelessWidget { final List banners; const BannersSection({ - Key? key, + super.key, required this.banners, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -22,10 +22,9 @@ class BannersSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - "Novedades".toUpperCase(), + Text("Novedades".toUpperCase(), textAlign: TextAlign.left, - style: Get.textTheme.titleMedium!.copyWith( + style: Theme.of(context).textTheme.titleMedium!.copyWith( fontWeight: FontWeight.bold, ), ), @@ -33,8 +32,7 @@ class BannersSection extends StatelessWidget { ListView.separated( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => - MiUtemBanner(banner: banners[index]), + itemBuilder: (context, index) => MiUtemBanner(banner: banners[index]), separatorBuilder: (context, index) => Container(height: 10), itemCount: banners.length, ), diff --git a/lib/widgets/permiso_card.dart b/lib/widgets/main_screen/permisos/permiso_card.dart similarity index 69% rename from lib/widgets/permiso_card.dart rename to lib/widgets/main_screen/permisos/permiso_card.dart index 876207f..2c14d06 100644 --- a/lib/widgets/permiso_card.dart +++ b/lib/widgets/main_screen/permisos/permiso_card.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/config/routes.dart'; -import 'package:mi_utem/models/permiso_covid.dart'; +import 'package:mi_utem/models/permiso_ingreso.dart'; +import 'package:mi_utem/screens/permiso_covid_screen.dart'; import 'package:mi_utem/themes/theme.dart'; class PermisoCard extends StatelessWidget { const PermisoCard({Key? key, required this.permiso}) : super(key: key); - final PermisoCovid permiso; + final PermisoIngreso permiso; @override Widget build(BuildContext context) { @@ -19,9 +18,7 @@ class PermisoCard extends StatelessWidget { child: Material( color: Colors.transparent, child: InkWell( - onTap: () => Get.toNamed( - '${Routes.passBase}/${permiso.id}', - ), + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (ctx) => PermisoCovidScreen(passId: "${permiso.id}"))), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -42,20 +39,17 @@ class PermisoCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(permiso.perfil!), - Text( - permiso.motivo!, - style: Get.textTheme.bodyLarge!.copyWith( + Text(permiso.motivo!, + style: Theme.of(context).textTheme.bodyLarge!.copyWith( color: MainTheme.primaryColor, fontWeight: FontWeight.bold, ), ), - if (permiso.campus != null) - Text( - "${permiso.campus ?? ''} (${permiso.campus!})", - style: Get.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - ), + if (permiso.campus != null)Text("${permiso.campus ?? ''} (${permiso.campus!})", + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, ), + ), ], ), ), @@ -66,12 +60,9 @@ class PermisoCard extends StatelessWidget { Container( height: 40, child: InkWell( - onTap: () => Get.toNamed( - '${Routes.passBase}/${permiso.id}', - ), + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (ctx) => PermisoCovidScreen(passId: "${permiso.id}"))), child: Center( - child: Text( - "Ver QR", + child: Text("Ver QR", style: TextStyle( color: Colors.black, fontWeight: FontWeight.w700, diff --git a/lib/widgets/main_screen/permisos/permisos_section.dart b/lib/widgets/main_screen/permisos/permisos_section.dart new file mode 100644 index 0000000..82734f9 --- /dev/null +++ b/lib/widgets/main_screen/permisos/permisos_section.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/models/exceptions/custom_exception.dart'; +import 'package:mi_utem/models/permiso_ingreso.dart'; +import 'package:mi_utem/repositories/permiso_ingreso_repository.dart'; +import 'package:mi_utem/widgets/loading/loading_indicator.dart'; +import 'package:mi_utem/widgets/main_screen/permisos/permiso_card.dart'; + +class PermisosCovidSection extends StatelessWidget { + + const PermisosCovidSection({ + super.key, + }); + + @override + Widget build(BuildContext context) => Column( + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Text("Permisos activos".toUpperCase(), + style: Theme.of(context).textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + mainAxisAlignment: MainAxisAlignment.spaceBetween, + ), + ), + SizedBox(height: 10), + SizedBox( + height: 155, + child: FutureBuilder?>( + future: Get.find().getPermisos(), + builder: (context, snapshot) { + if(snapshot.connectionState == ConnectionState.waiting) { + return LoadingIndicator.centeredDefault(); + } + + if(snapshot.hasError) { + final error = snapshot.error is CustomException ? (snapshot.error as CustomException).message : "Ocurrió un error al cargar los permisos"; + return Text(error); + } + + if(snapshot.data == null || snapshot.data?.isNotEmpty != true) { + return Text('No hay permisos de ingresos'); + } + + return ListView.separated( + itemCount: snapshot.data!.length, + padding: EdgeInsets.symmetric(horizontal: 20), + scrollDirection: Axis.horizontal, + separatorBuilder: (context, index) => Container(width: 10), + itemBuilder: (context, index) => PermisoCard( + permiso: snapshot.data![index], + ), + ); + }, + ), + ), + ], + ); +} diff --git a/lib/widgets/modals/persona_modal.dart b/lib/widgets/modals/persona_modal.dart new file mode 100644 index 0000000..164515c --- /dev/null +++ b/lib/widgets/modals/persona_modal.dart @@ -0,0 +1,79 @@ +import 'package:clipboard/clipboard.dart'; +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/user/persona.dart'; +import 'package:mi_utem/widgets/profile_photo.dart'; +import 'package:mi_utem/widgets/snackbar.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class PersonaModal extends StatelessWidget { + final Persona persona; + + const PersonaModal({ + super.key, + required this.persona, + }); + + @override + Widget build(BuildContext context) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Stack( + children: [ + Container( + alignment: Alignment.topCenter, + margin: const EdgeInsets.only(top: 80), + child: Card( + margin: const EdgeInsets.all(20), + child: ListView( + padding: const EdgeInsets.only(bottom: 10, top: 20), + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + children: [ + ListTile( + title: Text("Nombre Completo", + style: TextStyle(color: Colors.grey), + ), + onLongPress: () async { + await FlutterClipboard.copy(persona.nombreCompleto); + showTextSnackbar(context, title: "¡Copiado!", message: "Correo copiado al portapapeles"); + }, + subtitle: Text(persona.nombreCompletoCapitalizado, + style: TextStyle( + color: Colors.grey[900], + fontSize: 18, + ), + ), + ), + Divider(height: 5), + ListTile( + title: Text("Rut", + style: TextStyle(color: Colors.grey), + ), + onLongPress: () async { + await FlutterClipboard.copy(persona.rut.toString()); + showTextSnackbar(context, title: "¡Copiado!", message: "Rut copiado al portapapeles"); + }, + onTap: () async { + await launchUrl(Uri.parse("mailto:${persona.rut.toString()}")); + }, + subtitle: Text(persona.rut.toString(), + style: TextStyle( + color: Colors.grey[900], + fontSize: 18, + ), + ), + ), + ], + ), + ), + ), + Center( + child: ProfilePhoto( + iniciales: persona.iniciales, + radius: 60, + editable: false, + ), + ), + ], + ), + ); +} diff --git a/lib/widgets/nota_list_item.dart b/lib/widgets/nota_list_item.dart deleted file mode 100644 index dbfd375..0000000 --- a/lib/widgets/nota_list_item.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_masked_text/flutter_masked_text.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/controllers/calculator_controller.dart'; -import 'package:mi_utem/models/evaluacion.dart'; -import 'package:mi_utem/themes/theme.dart'; - -class NotaListItem extends StatelessWidget { - final IEvaluacion evaluacion; - final bool editable; - final TextEditingController? gradeController; - final TextEditingController? percentageController; - final Function(IEvaluacion)? onChanged; - final Function()? onDelete; - - NotaListItem({ - Key? key, - required this.evaluacion, - this.editable = false, - this.gradeController, - this.percentageController, - this.onChanged, - this.onDelete, - }) : super(key: key); - - final _controller = CalculatorController.to; - - String get _suggestedGrade { - return _controller.suggestedGrade?.toStringAsFixed(1) ?? "0.0"; - } - - String? get _suggestedPercentage { - return _controller.suggestedPercentage?.toStringAsFixed(0); - } - - @override - Widget build(BuildContext context) { - final defaultGradeController = MaskedTextController( - mask: "0.0", - text: evaluacion.nota?.toStringAsFixed(1) ?? "", - ); - final defaultPercentageController = MaskedTextController( - mask: "000", - text: evaluacion.porcentaje?.toStringAsFixed(0) ?? "", - ); - - final showSuggestedGrade = editable; - - final hintText = showSuggestedGrade ? _suggestedGrade : "--"; - - return Flex( - direction: Axis.horizontal, - mainAxisSize: MainAxisSize.max, - children: [ - Container( - width: 90, - child: Text( - evaluacion.descripcion ?? "Nota", - overflow: TextOverflow.ellipsis, - ), - ), - SizedBox(width: 16), - Flexible( - flex: 3, - child: Center( - child: TextField( - controller: gradeController ?? defaultGradeController, - enabled: editable, - onChanged: (String value) { - final grade = double.tryParse( - value.replaceAll(",", "."), - ); - - final changedGrade = evaluacion.copyWith(nota: grade); - changedGrade.nota = grade; - - onChanged?.call(changedGrade); - - //_controller.changeGradeAt(widget.index, changedGrade); - }, - textAlign: TextAlign.center, - decoration: InputDecoration( - hintText: hintText, - disabledBorder: - MainTheme.theme.inputDecorationTheme.border!.copyWith( - borderSide: BorderSide( - color: Colors.transparent, - ), - ), - ), - keyboardType: TextInputType.numberWithOptions(decimal: true), - ), - ), - ), - SizedBox(width: 16), - Flexible( - flex: 4, - child: Center( - child: TextField( - controller: percentageController ?? defaultPercentageController, - textAlign: TextAlign.center, - onChanged: (String value) { - final percentage = int.tryParse(value); - - final changedGrade = - evaluacion.copyWith(porcentaje: percentage); - changedGrade.porcentaje = percentage; - - onChanged?.call(changedGrade); - - //_controller.changeGradeAt(widget.index, changedGrade); - }, - enabled: editable, - decoration: InputDecoration( - hintText: _suggestedPercentage ?? "Peso", - suffixText: "%", - disabledBorder: - MainTheme.theme.inputDecorationTheme.border!.copyWith( - borderSide: BorderSide( - color: Colors.transparent, - ), - ), - ), - keyboardType: TextInputType.phone, - ), - ), - ), - SizedBox(width: 20), - if (onDelete != null) - GestureDetector( - onTap: () { - onDelete?.call(); - //_controller.removeGradeAt(widget.index); - }, - child: Icon( - Icons.delete, - color: Get.theme.primaryColor, - ), - ) - ], - ); - } -} diff --git a/lib/widgets/noticia_card.dart b/lib/widgets/noticia_card.dart deleted file mode 100644 index a2c97f1..0000000 --- a/lib/widgets/noticia_card.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:get/get.dart'; -import 'package:mdi/mdi.dart'; - -class NoticiaCard extends StatelessWidget { - const NoticiaCard( - {Key? key, this.titulo, this.subtitulo, this.imagenUrl, this.onTap}) - : super(key: key); - - final String? titulo, subtitulo, imagenUrl; - final Function? onTap; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 200, - width: 200, - child: Card( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => this.onTap!(), - borderRadius: BorderRadius.all(Radius.circular(15)), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - imagenUrl != null - ? Image.network( - imagenUrl!, - height: 110, - fit: BoxFit.cover, - ) - : Container( - height: 110, - width: double.infinity, - color: Colors.grey, - child: Icon( - Mdi.imageOff, - color: Colors.white, - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10), - height: 70, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Spacer(), - Text( - titulo!, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: Get.theme.textTheme.bodyLarge, - ), - - /* Text( - subtitulo, - overflow: TextOverflow.ellipsis, - style: Get.theme.textTheme.bodyText2, - maxLines: 2 - ) */ - Spacer(), - ], - ), - ) - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/noticias/noticia_card_widget.dart b/lib/widgets/noticias/noticia_card_widget.dart new file mode 100644 index 0000000..57dd82c --- /dev/null +++ b/lib/widgets/noticias/noticia_card_widget.dart @@ -0,0 +1,72 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/noticia.dart'; +import 'package:mi_utem/services/analytics_service.dart'; +import 'package:mi_utem/widgets/loading/loading_indicator.dart'; +import 'package:mi_utem/widgets/snackbar.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class NoticiaCardWidget extends StatelessWidget { + + final Noticia noticia; + + const NoticiaCardWidget({ + required this.noticia, + }); + + @override + Widget build(BuildContext context) => SizedBox( + height: 230, + width: 250, + child: Card( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _onTapNoticia(context), + borderRadius: BorderRadius.all(Radius.circular(15)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + CachedNetworkImage( + imageUrl: noticia.imagen, + placeholder: (ctx, url) => LoadingIndicator(), + errorWidget: (ctx, url, error) => Icon(Icons.error, size: 110, color: Theme.of(context).colorScheme.error), + height: 110, + fit: BoxFit.cover, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: SizedBox( + height: 100, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Spacer(), + Text(noticia.titulo, + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge, + ), + const Spacer(), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + + _onTapNoticia(BuildContext context) async { + final url = noticia.link; + if (await canLaunchUrl(Uri.parse(url))) { + AnalyticsService.logEvent("noticia_card_tap"); + await launchUrl(Uri.parse(url)); + } else { + showErrorSnackbar(context, "No se pudo abrir la noticia. Intenta más tarde."); + } + } +} diff --git a/lib/widgets/noticias/noticias_carrusel_widget.dart b/lib/widgets/noticias/noticias_carrusel_widget.dart new file mode 100644 index 0000000..4fbdf78 --- /dev/null +++ b/lib/widgets/noticias/noticias_carrusel_widget.dart @@ -0,0 +1,65 @@ +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mi_utem/config/logger.dart'; +import 'package:mi_utem/models/exceptions/custom_exception.dart'; +import 'package:mi_utem/models/noticia.dart'; +import 'package:mi_utem/repositories/noticias_repository.dart'; +import 'package:mi_utem/widgets/custom_error_widget.dart'; +import 'package:mi_utem/widgets/loading/loading_indicator.dart'; +import 'package:mi_utem/widgets/noticias/noticia_card_widget.dart'; + +class NoticiasCarruselWidget extends StatelessWidget { + + const NoticiasCarruselWidget({ + super.key, + }); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text("Noticias".toUpperCase(), + style: Theme.of(context).textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 10), + FutureBuilder( + future: Get.find().getNoticias(), + builder: (context, AsyncSnapshot> snapshot) { + if (snapshot.hasError) { + final error = snapshot.error is CustomException ? (snapshot.error as CustomException) : CustomException.custom("No pudimos obtener las noticias."); + logger.d("[NoticiasCarruselWidget] ${error.message} (${error.statusCode})", snapshot.error, snapshot.stackTrace); + + return CustomErrorWidget( + title: "Ocurrió un error al cargar las noticias", + error: error.message, + ); + } + + List? noticias = snapshot.data; + + if(!snapshot.hasData || noticias == null || noticias.isEmpty) { + return Center(child: LoadingIndicator()); + } + + return CarouselSlider.builder( + options: CarouselOptions( + autoPlay: true, + height: 230, + viewportFraction: MediaQuery.of(context).orientation == Orientation.landscape ? 0.3 : 0.5, + initialPage: 0, + ), + itemBuilder: (BuildContext context, int i, int rI) => NoticiaCardWidget(noticia: noticias[i]), + itemCount: noticias.length, + ); + }, + ), + ], + ); +} diff --git a/lib/widgets/noticias_carrusel.dart b/lib/widgets/noticias_carrusel.dart deleted file mode 100644 index fa0ec57..0000000 --- a/lib/widgets/noticias_carrusel.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:carousel_slider/carousel_slider.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/models/noticia.dart'; -import 'package:mi_utem/services/analytics_service.dart'; -import 'package:mi_utem/services/noticias_service.dart'; -import 'package:mi_utem/widgets/custom_error_widget.dart'; -import 'package:mi_utem/widgets/loading_indicator.dart'; -import 'package:mi_utem/widgets/noticia_card.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class NoticiasSection extends StatefulWidget { - NoticiasSection({Key? key}) : super(key: key); - - @override - State createState() => _NoticiasSectionState(); -} - -class _NoticiasSectionState extends State { - Future>? _noticiasFuture; - - @override - void initState() { - super.initState(); - _noticiasFuture = _getNoticias(); - } - - Future> _getNoticias() async { - List noticias = await NoticiasService.getNoticias(); - return noticias; - } - - Future _launchURL(String url) async { - if (await canLaunchUrl(Uri.parse(url))) { - await launchUrl(Uri.parse(url)); - } else { - throw 'Could not launch $url'; - } - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: EdgeInsets.symmetric(horizontal: 20), - child: Text( - "Noticias".toUpperCase(), - style: Get.textTheme.titleMedium!.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Container(height: 10), - FutureBuilder>( - future: _noticiasFuture, - builder: (context, snapshot) { - if (snapshot.hasError) { - return CustomErrorWidget( - title: "Ocurrió un error al obtener las noticias", - error: snapshot.error); - } else { - if (snapshot.hasData && snapshot.data!.length > 0) { - List noticias = snapshot.data!; - return CarouselSlider.builder( - options: CarouselOptions( - autoPlay: true, - height: 200, - viewportFraction: 0.5, - initialPage: 0, - ), - itemBuilder: (BuildContext context, int i, int rI) => - NoticiaCard( - titulo: noticias[i].titulo, - subtitulo: noticias[i].subtitulo, - imagenUrl: noticias[i].featuredMedia?.guid, - onTap: () { - AnalyticsService.logEvent("noticia_tap"); - _launchURL(noticias[i].link!); - }, - ), - itemCount: noticias.length, - ); - } else { - return Center(child: LoadingIndicator()); - } - } - }, - ), - ], - ); - } -} diff --git a/lib/widgets/permiso_ingreso/detalles_permiso.dart b/lib/widgets/permiso_ingreso/detalles_permiso.dart new file mode 100644 index 0000000..ee95a82 --- /dev/null +++ b/lib/widgets/permiso_ingreso/detalles_permiso.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/widgets/field_list_tile.dart'; + +class DetallesPermiso extends StatelessWidget { + const DetallesPermiso({ + super.key, + this.campus, + this.dependencias, + this.jornada, + this.vigencia, + this.motivo, + }); + + final String? campus, dependencias, jornada, vigencia, motivo; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FieldListTile( + padding: EdgeInsets.zero, + title: "Motivo", + value: motivo, + ), + if (campus != null || dependencias != null) Container(height: 20), + if (campus != null || dependencias != null) + Row( + children: [ + Expanded( + child: FieldListTile( + padding: EdgeInsets.zero, + title: "Campus", + value: campus, + ), + ), + Expanded( + child: FieldListTile( + padding: EdgeInsets.zero, + title: "Dependencias", + value: dependencias, + ), + ), + ], + ), + if (jornada != null || vigencia != null) Container(height: 20), + if (jornada != null || vigencia != null)Row( + children: [ + Expanded( + child: FieldListTile( + padding: EdgeInsets.zero, + title: "Jornada", + value: jornada, + ), + ), + Expanded( + child: FieldListTile( + padding: EdgeInsets.zero, + title: "Vigencia", + value: vigencia, + ), + ), + ], + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/permiso_ingreso/qr_card.dart b/lib/widgets/permiso_ingreso/qr_card.dart new file mode 100644 index 0000000..0e7a7bc --- /dev/null +++ b/lib/widgets/permiso_ingreso/qr_card.dart @@ -0,0 +1,94 @@ +import 'dart:typed_data'; + +import 'package:barcode_image/barcode_image.dart'; +import 'package:barcode_widget/barcode_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_uxcam/flutter_uxcam.dart'; +import 'package:image/image.dart' as dartImage; +import 'package:intl/intl.dart'; +import 'package:mi_utem/models/permiso_ingreso.dart'; +import 'package:mi_utem/widgets/image/image_view_screen.dart'; +import 'package:mi_utem/widgets/permiso_ingreso/detalles_permiso.dart'; +import 'package:mi_utem/widgets/permiso_ingreso/usuario_detalle.dart'; + +class QRCard extends StatelessWidget { + const QRCard({ + super.key, + required this.permiso, + }); + + final PermisoIngreso permiso; + + _openQr(BuildContext context, String heroTag) { + final image = dartImage.Image(500, 500); + + dartImage.fill(image, dartImage.getColor(255, 255, 255)); + drawBarcode( + image, + Barcode.qrCode(), + permiso.codigoQr!, + x: 25, + y: 25, + width: 450, + height: 450, + ); + + Uint8List data = Uint8List.fromList(dartImage.encodePng(image)); + + Navigator.push(context, MaterialPageRoute(builder: (ctx) => ImageViewScreen( + imageProvider: MemoryImage(data), + heroTag: heroTag, + occlude: true, + ))); + } + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(20.0), + child: Card( + child: Column( + children: [ + UsuarioDetalle( + user: permiso.user!, + ), + Divider(thickness: 1, color: Color(0xFFFEEEEE)), + DetallesPermiso( + campus: permiso.campus, + dependencias: permiso.dependencia, + jornada: permiso.jornada, + vigencia: permiso.vigencia, + motivo: permiso.motivo, + ), + Divider(thickness: 1, color: Color(0xFFFEEEEE)), + Container(height: 20), + Center( + child: InkWell( + onTap: () => _openQr(context, "qr_${permiso.codigoQr!}"), + child: Hero( + tag: "qr_${permiso.codigoQr!}", + child: Container( + color: Colors.white, + padding: EdgeInsets.all(10), + child: OccludeWrapper( + child: BarcodeWidget( + barcode: Barcode.qrCode(), + height: 200, + width: 200, + data: permiso.codigoQr!, + drawText: false, + ), + ), + ), + ), + ), + ), + Container(height: 20), + Text("Permiso generado el ${DateFormat('dd/MM/yyyy').format(permiso.fechaSolicitud!)}", + style: Theme.of(context).textTheme.bodySmall, + ), + Container(height: 20), + ], + ), + ), + ); +} \ No newline at end of file diff --git a/lib/widgets/permiso_ingreso/usuario_detalle.dart b/lib/widgets/permiso_ingreso/usuario_detalle.dart new file mode 100644 index 0000000..f7044a7 --- /dev/null +++ b/lib/widgets/permiso_ingreso/usuario_detalle.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/models/user/user.dart'; +import 'package:mi_utem/widgets/profile_photo.dart'; + +class UsuarioDetalle extends StatelessWidget { + final User user; + + const UsuarioDetalle({ + super.key, + required this.user, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(20.0), + child: Row( + children: [ + ProfilePhoto( + fotoUrl: user.fotoUrl, + iniciales: user.iniciales, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(user.nombreCompletoCapitalizado, + maxLines: 2, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 4), + Text("${user.rut}", + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/permisos_section.dart b/lib/widgets/permisos_section.dart deleted file mode 100644 index 84a11d8..0000000 --- a/lib/widgets/permisos_section.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/controllers/qr_passes_controller.dart'; -import 'package:mi_utem/widgets/loading_indicator.dart'; -import 'package:mi_utem/widgets/permiso_card.dart'; - -class PermisosCovidSection extends GetView { - const PermisosCovidSection({ - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Container( - padding: EdgeInsets.symmetric(horizontal: 20), - child: Row( - children: [ - Text( - "Permisos activos".toUpperCase(), - style: Get.textTheme.titleMedium!.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - mainAxisAlignment: MainAxisAlignment.spaceBetween, - ), - ), - Container(height: 10), - SizedBox( - height: 155, - child: Obx( - () { - if (controller.isLoading.value) { - return LoadingIndicator( - message: "Esto tardará un poco, paciencia...", - ); - } - - if (controller.passes.length == 0) { - return Text('No hay permisos de ingresos'); - } - - return ListView.separated( - itemCount: controller.passes.length, - padding: EdgeInsets.symmetric(horizontal: 20), - scrollDirection: Axis.horizontal, - separatorBuilder: (context, index) => Container(width: 10), - itemBuilder: (context, index) => PermisoCard( - permiso: controller.passes[index], - ), - ); - }, - ), - ), - ], - ); - } -} diff --git a/lib/widgets/profile_photo.dart b/lib/widgets/profile_photo.dart index dc45155..e688e70 100644 --- a/lib/widgets/profile_photo.dart +++ b/lib/widgets/profile_photo.dart @@ -4,57 +4,55 @@ import 'dart:typed_data'; import 'package:circular_profile_avatar/circular_profile_avatar.dart'; import 'package:flutter/material.dart'; -import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:mi_utem/models/usuario.dart'; +import 'package:mi_utem/config/logger.dart'; import 'package:mi_utem/themes/theme.dart'; -import 'package:mi_utem/widgets/imagen_editor_modal.dart'; +import 'package:mi_utem/widgets/image/imagen_editor_modal.dart'; +import 'package:mi_utem/widgets/snackbar.dart'; class ProfilePhoto extends StatefulWidget { final double radius; - final Usuario? usuario; + final String? fotoUrl; + final String iniciales; final Function(BuildContext, ImageProvider)? onImageTap; final Function()? onTap; final double borderWidth; final Color borderColor; final bool editable; final Function(String)? onImage; - ProfilePhoto( - {Key? key, - this.onTap, - this.onImageTap, - this.radius = 25, - this.borderColor = Colors.white, - this.borderWidth = 0.0, - required this.usuario, - this.onImage, - this.editable = false}) - : super(key: key); + + ProfilePhoto({ + super.key, + required this.iniciales, + this.fotoUrl, + this.onTap, + this.onImageTap, + this.radius = 25, + this.borderColor = Colors.white, + this.borderWidth = 0.0, + this.onImage, + this.editable = false + }); @override _ProfilePhotoState createState() => _ProfilePhotoState(); } class _ProfilePhotoState extends State { - ImagePicker picker = ImagePicker(); - - @override - void initState() { - super.initState(); - } + final _picker = ImagePicker(); @override - Widget build(BuildContext context) { - return Container( - width: widget.radius * 2, - height: widget.radius * 2, - child: Stack( - children: [ - CircularProfileAvatar( - widget.usuario!.fotoUrl ?? "", - onTap: () => widget.onTap != null && widget.onImageTap == null - ? widget.onTap - : null, + Widget build(BuildContext context) => Container( + width: widget.radius * 2, + height: widget.radius * 2, + child: Stack( + children: [ + FutureBuilder( + // future: Get.find().getProfilePicture(), + initialData: null, + builder: (ctx, snapshot) => CircularProfileAvatar(snapshot.data != null ? '' : (widget.fotoUrl ?? ""), + child: snapshot.data != null ? Image.memory(Base64Decoder().convert(snapshot.data!)) : null, + onTap: () => widget.onTap != null && widget.onImageTap == null ? widget.onTap : null, borderColor: widget.borderColor, borderWidth: widget.borderWidth, radius: widget.radius, @@ -77,73 +75,52 @@ class _ProfilePhotoState extends State { ), ), ), - initialsText: Text( - widget.usuario!.iniciales, + initialsText: Text(widget.iniciales, style: TextStyle( fontSize: widget.radius * 0.5, color: Colors.white, ), ), ), - if (widget.editable) - InkWell( - onTap: () async { - try { - final imagen = await picker.pickImage( - source: ImageSource.gallery, - ); - if (imagen != null) { - Uint8List imagenOriginalBytes = - File(imagen.path).readAsBytesSync(); + ), + if (widget.editable) + InkWell( + onTap: () async { + try { + final imagen = await _picker.pickImage(source: ImageSource.gallery); + if (imagen == null) { + showErrorSnackbar(context, "No se pudo obtener la foto"); + return; + } - Uint8List imagenEditadaBytes = await Get.to( - () => ImagenEditorModal( - imagenInicial: imagenOriginalBytes, - aspectRatio: 1, - ), - ); + Uint8List imagenOriginalBytes = + File(imagen.path).readAsBytesSync(); - String imagenEditadaBase64 = - base64Encode(imagenEditadaBytes); + Uint8List imagenEditadaBytes = await Navigator.push(context, MaterialPageRoute(builder: (ctx) => ImagenEditorModal(imagenInicial: imagenOriginalBytes, aspectRatio: 1))); - widget.onImage!(imagenEditadaBase64); - } else { - Get.snackbar( - "Error", - "No se pudo obtener la foto", - colorText: Colors.white, - backgroundColor: Get.theme.primaryColor, - snackPosition: SnackPosition.BOTTOM, - margin: EdgeInsets.all(20), - ); - } - } catch (e) { - print(e); - Get.snackbar( - "Error", - "No se pudo cambiar la foto", - colorText: Colors.white, - backgroundColor: Get.theme.primaryColor, - snackPosition: SnackPosition.BOTTOM, - margin: EdgeInsets.all(20), - ); - } - }, - borderRadius: BorderRadius.circular(25), - child: Container( - padding: EdgeInsets.all(5), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(30), - ), - child: Icon( - Icons.camera_alt, - color: Get.theme.primaryColor, - ), + String imagenEditadaBase64 = + base64Encode(imagenEditadaBytes); + + widget.onImage!(imagenEditadaBase64); + } catch (e) { + logger.e("Error al cambiar la foto", e); + showErrorSnackbar(context, "No se pudo cambiar la foto"); + } + }, + borderRadius: BorderRadius.circular(25), + child: Container( + padding: EdgeInsets.all(5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(30), + ), + child: Icon( + Icons.camera_alt, + color: Theme.of(context).primaryColor, ), ), - ], - ), - ); - } + ), + ], + ), + ); } diff --git a/lib/widgets/pull_to_refresh.dart b/lib/widgets/pull_to_refresh.dart index 4dc6ff9..3cfd530 100644 --- a/lib/widgets/pull_to_refresh.dart +++ b/lib/widgets/pull_to_refresh.dart @@ -1,73 +1,64 @@ -import 'package:flutter/material.dart'; - import 'package:custom_refresh_indicator/custom_refresh_indicator.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; - import 'package:mi_utem/themes/theme.dart'; -import 'package:mi_utem/widgets/loading_indicator.dart'; +import 'package:mi_utem/widgets/loading/loading_indicator.dart'; class PullToRefresh extends StatelessWidget { final Widget? child; final Future Function() onRefresh; final bool opacityEffect; - const PullToRefresh( - {Key? key, - this.child, - required this.onRefresh, - this.opacityEffect = false}) - : super(key: key); + const PullToRefresh({ + super.key, + this.child, + required this.onRefresh, + this.opacityEffect = false + }); static double _offsetToArmed = 60; @override - Widget build(BuildContext context) { - return CustomRefreshIndicator( - offsetToArmed: _offsetToArmed, - onRefresh: onRefresh, - builder: (context, child, controller) => Stack( - children: [ - AnimatedBuilder( - child: child, - animation: controller, - builder: (context, child) { - return Opacity( - opacity: 1.0 - - (opacityEffect ? controller.value.clamp(0.0, 1.0) : 0), - child: Transform.translate( - offset: Offset(0.0, (_offsetToArmed) * controller.value), - child: child, - ), - ); - }, + Widget build(BuildContext context) => CustomRefreshIndicator( + offsetToArmed: _offsetToArmed, + onRefresh: onRefresh, + builder: (context, child, controller) => Stack( + children: [ + AnimatedBuilder( + child: child, + animation: controller, + builder: (context, child) => Opacity( + opacity: 1.0 - (opacityEffect ? controller.value.clamp(0.0, 1.0) : 0), + child: Transform.translate( + offset: Offset(0.0, (_offsetToArmed) * controller.value), + child: child, + ), ), - AnimatedBuilder( - child: LoadingIndicator(padding: EdgeInsets.zero), - animation: controller, - builder: (context, child) { - return SafeArea( - child: Stack( - children: [ - Container( - height: (_offsetToArmed) * controller.value, - width: double.infinity, - child: Container( - height: 30, - width: 30, - child: SpinKitDoubleBounce( - color: MainTheme.primaryColor, - size: 20.0, - ), - ), + ), + AnimatedBuilder( + child: const LoadingIndicator(padding: EdgeInsets.zero), + animation: controller, + builder: (context, child) => SafeArea( + child: Stack( + children: [ + SizedBox( + height: (_offsetToArmed) * controller.value, + width: double.infinity, + child: SizedBox( + height: 30, + width: 30, + child: SpinKitDoubleBounce( + color: MainTheme.primaryColor, + size: 20.0, ), - ], + ), ), - ); - }, + ], + ), ), - ], - ), - child: child!, - ); - } + ), + ], + ), + child: child!, + ); } diff --git a/lib/widgets/quick_access/quick_menu_card.dart b/lib/widgets/quick_access/quick_menu_card.dart new file mode 100644 index 0000000..d53f2b7 --- /dev/null +++ b/lib/widgets/quick_access/quick_menu_card.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:gradient_widgets/gradient_widgets.dart'; +import 'package:hexcolor/hexcolor.dart'; +import 'package:mi_utem/screens/asignatura/asignaturas_lista_screen.dart'; +import 'package:mi_utem/screens/calculadora_notas_screen.dart'; +import 'package:mi_utem/screens/credencial_screen.dart'; +import 'package:mi_utem/screens/horario/horario_screen.dart'; + +class QuickMenuCard extends StatelessWidget { + final Map card; + + const QuickMenuCard({ + super.key, + required this.card + }); + + Widget? get _route { + switch (card["route"]) { + case "/AsignaturasScreen": + return AsignaturasListaScreen(); + case "/HorarioScreen": + return HorarioScreen(); + case "/CalculadoraNotasScreen": + return CalculadoraNotasScreen(); + case "/CredencialScreen": + return CredencialScreen(); + default: + return null; + } + } + + @override + Widget build(BuildContext context) => Container( + height: 130, + width: 150, + child: GradientCard( + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + ), + gradient: LinearGradient( + colors: card["degradado"]["colors"] + .map((dynamic c) => HexColor(c.toString())) + .toList(), + stops: card["degradado"]["stops"] + ?.map((num s) => s.toDouble()) + .toList(), + begin: card["degradado"]["begin"] != null + ? Alignment( + card["degradado"]["begin"][0].toDouble(), + card["degradado"]["begin"][1].toDouble(), + ) + : Alignment.centerLeft, + end: card["degradado"]["end"] != null + ? Alignment( + card["degradado"]["end"][0].toDouble(), + card["degradado"]["end"][1].toDouble(), + ) + : Alignment.centerRight, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + final route = _route; + if(route != null) { + Navigator.push(context, MaterialPageRoute(builder: (ctx) => route)); + } + }, + borderRadius: BorderRadius.all(Radius.circular(15)), + child: Container( + padding: EdgeInsets.all(20), + width: double.infinity, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + IconData(card["icono"]["codePoint"], + fontFamily: card["icono"]["fontFamily"], + fontPackage: card["icono"]["fontPackage"], + ), + color: Colors.white, + size: 30, + ), + Container(height: 10), + Text(card["nombre"], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge!.copyWith( + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ), + ); +} diff --git a/lib/widgets/quick_access/quick_menu_section.dart b/lib/widgets/quick_access/quick_menu_section.dart new file mode 100644 index 0000000..9c55615 --- /dev/null +++ b/lib/widgets/quick_access/quick_menu_section.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/services/remote_config/remote_config.dart'; +import 'package:mi_utem/widgets/quick_access/quick_menu_card.dart'; + +class QuickMenuSection extends StatelessWidget { + const QuickMenuSection({ + super.key + }); + + List> get _quickMenu => RemoteConfigService.quickMenu; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text("Acceso rápido".toUpperCase(), + style: Theme.of(context).textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(height: 10), + SizedBox( + height: 130, + child: ListView.separated( + itemCount: _quickMenu.length, + padding: const EdgeInsets.symmetric(horizontal: 20), + scrollDirection: Axis.horizontal, + separatorBuilder: (context, index) => const SizedBox(width: 10), + itemBuilder: (context, index) => QuickMenuCard(card: _quickMenu[index]), + ), + ), + ], + ); +} diff --git a/lib/widgets/quick_menu_card.dart b/lib/widgets/quick_menu_card.dart deleted file mode 100644 index a333747..0000000 --- a/lib/widgets/quick_menu_card.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:gradient_widgets/gradient_widgets.dart'; -import 'package:hexcolor/hexcolor.dart'; -import 'package:mi_utem/config/routes.dart'; - -class QuickMenuCard extends StatelessWidget { - const QuickMenuCard({Key? key, required this.card}) : super(key: key); - - final Map card; - - String? get _route { - switch (card["route"]) { - case "/AsignaturasScreen": - return Routes.asignaturas; - case "/HorarioScreen": - return Routes.horario; - case "/CalculadoraNotasScreen": - return Routes.calculadoraNotas; - case "/CredencialScreen": - return Routes.credencial; - default: - return null; - } - } - - @override - Widget build(BuildContext context) { - return Container( - height: 130, - width: 150, - child: GradientCard( - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15.0), - ), - gradient: LinearGradient( - colors: card["degradado"]["colors"] - .map((dynamic c) => HexColor(c.toString())) - .toList(), - stops: card["degradado"]["stops"] - ?.map((num s) => s.toDouble()) - .toList(), - begin: card["degradado"]["begin"] != null - ? Alignment( - card["degradado"]["begin"][0].toDouble(), - card["degradado"]["begin"][1].toDouble(), - ) - : Alignment.centerLeft, - end: card["degradado"]["end"] != null - ? Alignment( - card["degradado"]["end"][0].toDouble(), - card["degradado"]["end"][1].toDouble(), - ) - : Alignment.centerRight, - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: _route != null - ? () { - Get.toNamed( - _route!, - ); - } - : null, - borderRadius: BorderRadius.all(Radius.circular(15)), - child: Container( - padding: EdgeInsets.all(20), - width: double.infinity, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - IconData( - card["icono"]["codePoint"], - fontFamily: card["icono"]["fontFamily"], - fontPackage: card["icono"]["fontPackage"], - ), - color: Colors.white, - size: 30, - ), - Container(height: 10), - Text( - card["nombre"], - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Get.textTheme.titleLarge!.copyWith( - color: Colors.white, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/quick_menu_section.dart b/lib/widgets/quick_menu_section.dart deleted file mode 100644 index e75124c..0000000 --- a/lib/widgets/quick_menu_section.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:mi_utem/services/remote_config/remote_config.dart'; -import 'package:mi_utem/widgets/quick_menu_card.dart'; - -class QuickMenuSection extends StatelessWidget { - const QuickMenuSection({Key? key}) : super(key: key); - - List get _quickMenu { - return jsonDecode(RemoteConfigService.quickMenu); - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: EdgeInsets.symmetric(horizontal: 20), - child: Text( - "Acceso rápido".toUpperCase(), - style: Get.textTheme.titleMedium!.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ), - Container(height: 10), - Container( - height: 130, - child: ListView.separated( - itemCount: _quickMenu.length, - padding: EdgeInsets.symmetric(horizontal: 20), - scrollDirection: Axis.horizontal, - separatorBuilder: (context, index) => Container(width: 10), - itemBuilder: (context, index) { - Map e = _quickMenu[index]; - return QuickMenuCard(card: e); - }, - ), - ), - ], - ); - } -} diff --git a/lib/widgets/semestre_boletin_card.dart b/lib/widgets/semestre_boletin_card.dart deleted file mode 100644 index 234ab66..0000000 --- a/lib/widgets/semestre_boletin_card.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:get/get.dart'; - -class SemestreBoletinCard extends StatelessWidget { - final String? semestre; - final int? aprobados; - final int? reprobados; - final int? convalidados; - final double? promedio; - final List? ramos; - - SemestreBoletinCard( - {Key? key, - this.semestre, - this.aprobados, - this.reprobados, - this.convalidados, - this.promedio, - this.ramos}) - : super(key: key); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(left: 10.0, right: 10.0), - child: Card( - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - color: Color(0xFFD8BFD8), - child: Container( - padding: EdgeInsets.symmetric(vertical: 10), - width: Get.mediaQuery.size.width * 0.4, - child: Column( - children: [ - Text("Promedio", - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.black.withOpacity(0.6))), - Text(promedio.toString(), - style: TextStyle( - color: Colors.black.withOpacity(0.6))) - ], - ), - )), - Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - color: Color(0xFF98FB98), - child: Container( - padding: EdgeInsets.symmetric(vertical: 10), - width: Get.mediaQuery.size.width * 0.4, - child: Column( - children: [ - Text("Aprobados", - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.black.withOpacity(0.6))), - Text(aprobados.toString(), - style: TextStyle( - color: Colors.black.withOpacity(0.6))) - ], - ), - )), - ], - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - color: Color(0xFFFFC0CB), - child: Container( - padding: EdgeInsets.symmetric(vertical: 10), - width: Get.mediaQuery.size.width * 0.4, - child: Column( - children: [ - Text("Reprobados", - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.black.withOpacity(0.6))), - Text(reprobados.toString(), - style: TextStyle( - color: Colors.black.withOpacity(0.6))) - ], - ), - )), - Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - color: Color(0xFFFFDEAD), - child: Container( - padding: EdgeInsets.symmetric(vertical: 10), - width: Get.mediaQuery.size.width * 0.4, - child: Column( - children: [ - Text("Convalidados", - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.black.withOpacity(0.6))), - Text(convalidados.toString(), - style: TextStyle( - color: Colors.black.withOpacity(0.6))) - ], - ), - )) - ], - ), - Column( - children: [ - Container( - padding: EdgeInsets.symmetric(vertical: 20), - child: new Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: Get.mediaQuery.size.width * 0.5, - child: Text("Asignatura", - textAlign: TextAlign.center, - style: - TextStyle(fontWeight: FontWeight.bold))), - Container( - width: Get.mediaQuery.size.width * 0.2, - child: Text("Estado", - textAlign: TextAlign.center, - style: - TextStyle(fontWeight: FontWeight.bold))), - Container( - width: Get.mediaQuery.size.width * 0.1, - child: Text("Nota", - textAlign: TextAlign.center, - style: - TextStyle(fontWeight: FontWeight.bold))), - ])), - new ListView.separated( - shrinkWrap: true, - physics: ClampingScrollPhysics(), - itemCount: ramos!.length, - separatorBuilder: (BuildContext context, int i) => Divider( - indent: 20, - endIndent: 20, - ), - itemBuilder: (BuildContext context, int i) { - return new Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: EdgeInsets.only(top: 5, bottom: 5), - width: Get.mediaQuery.size.width * 0.5, - child: Text( - ramos![i]["nombre"], - textAlign: TextAlign.center, - )), - Container( - padding: EdgeInsets.only(top: 5, bottom: 5), - width: Get.mediaQuery.size.width * 0.2, - child: Text( - ramos![i]["estado"].substring(0, 1), - textAlign: TextAlign.center, - )), - Container( - padding: EdgeInsets.only(top: 5, bottom: 5), - width: Get.mediaQuery.size.width * 0.1, - child: Text( - ramos![i]["nota"].toString(), - textAlign: TextAlign.center, - )), - ]); - }, - ), - ], - ), - ], - ), - color: Colors.white, - ), - ); - } -} diff --git a/lib/widgets/snackbar.dart b/lib/widgets/snackbar.dart new file mode 100644 index 0000000..2adc3b2 --- /dev/null +++ b/lib/widgets/snackbar.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:mi_utem/themes/theme.dart'; + +void showErrorSnackbar(BuildContext context, String message, { Function()? onTap }) => showTextSnackbar(context, title: "Error", message: message, backgroundColor: Colors.red, onTap: onTap); + +void showTextSnackbar(BuildContext context, { + required String title, + required String message, + Color? backgroundColor, + Color? textColor, + Duration? duration, + Function()? onTap, +}) => showSnackbar(context, + content: GestureDetector( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontWeight: FontWeight.bold, color: textColor ?? Colors.white)), + Text(message, style: TextStyle(color: textColor ?? Colors.white)), + ], + ), + ), + backgroundColor: backgroundColor, + duration: duration, +); + +void showSnackbar(BuildContext context, { + required Widget content, + Color? backgroundColor, + Duration? duration, +}) => ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: content, + backgroundColor: backgroundColor ?? MainTheme.primaryColor, + behavior: SnackBarBehavior.floating, + duration: duration ?? const Duration(seconds: 5), +)); \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 994e81d..9e0becc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: "867b77e2367bc502dcd4d5a66302615409f04eb20ed82ba1c0ba073f9107e018" + sha256: "2d8e8e123ca3675625917f535fcc0d3a50092eef44334168f9b18adc050d4c6e" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.6" analyzer: dependency: transitive description: @@ -45,18 +45,18 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: bfe67ef28df125b7dddcea62755991f807aa39a2492a23e1550161692950bbe0 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.10.0" awesome_notifications: dependency: "direct main" description: name: awesome_notifications - sha256: "2b430c75cc879d6cfd52bb6eb2b5c1591ed425347816408cdcbd3f6916bba14c" + sha256: "65f730f9c0e73a346039ef746384bcff1773f9f03821b859705a7ab8db977b23" url: "https://pub.dev" source: hosted - version: "0.7.4+1" + version: "0.8.2" awesome_notifications_core: dependency: "direct main" description: @@ -69,10 +69,10 @@ packages: dependency: "direct main" description: name: awesome_notifications_fcm - sha256: "3bcfc54360128bd9a83f558c6652681d58ba158a5237f15536fd4c6da46d99f2" + sha256: "0e44272fc734a5f42af063688fa172bac58f79fcc98d8930cc3604a0ecba0bea" url: "https://pub.dev" source: hosted - version: "0.7.4+1" + version: "0.8.0" background_fetch: dependency: "direct main" description: @@ -173,27 +173,10 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - charts_common: - dependency: transitive - description: - name: charts_common - sha256: "7b8922f9b0d9b134122756a787dab1c3946ae4f3fc5022ff323ba0014998ea02" + sha256: e6a326c8af69605aec75ed6c187d06b349707a27fbff8222ca9cc2cff167975c url: "https://pub.dev" source: hosted - version: "0.12.0" - charts_flutter: - dependency: "direct main" - description: - path: charts_flutter - ref: HEAD - resolved-ref: fcd0c524ac573ae4d0b79f3bdd6491fbbcdbfb61 - url: "https://github.com/google/charts" - source: git - version: "0.12.0" + version: "1.2.1" checked_yaml: dependency: transitive description: @@ -262,10 +245,26 @@ packages: dependency: transitive description: name: collection - sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0 url: "https://pub.dev" source: hosted - version: "1.17.2" + version: "1.17.0" + community_charts_common: + dependency: transitive + description: + name: community_charts_common + sha256: "20697244c826df0545237ebe01d61caa96a2a2e4d23c6f88890441636a4d5220" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + community_charts_flutter: + dependency: "direct main" + description: + name: community_charts_flutter + sha256: ca5bd07337e162daee13c19679f602cd8b3f704520d242beeebbc2e312f84f89 + url: "https://pub.dev" + source: hosted + version: "1.0.2" convert: dependency: transitive description: @@ -283,7 +282,7 @@ packages: source: hosted version: "0.3.3+5" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab @@ -322,22 +321,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.6" - dio_cache_interceptor: - dependency: "direct main" - description: - name: dio_cache_interceptor - sha256: a89166e6fb9c90a4bf2b7f20b5c055087969bc445ced3282e32505543e296e0f - url: "https://pub.dev" - source: hosted - version: "3.4.2" - dio_cache_interceptor_hive_store: - dependency: "direct main" - description: - name: dio_cache_interceptor_hive_store - sha256: "7a376b1db0a153e16ad51ce0cf1d2549ca14a2ddf462523c362fac9e077c5f14" - url: "https://pub.dev" - source: hosted - version: "3.2.1" dio_http_cache: dependency: "direct main" description: @@ -358,18 +341,26 @@ packages: dependency: "direct main" description: name: extended_image - sha256: b4d72a27851751cfadaf048936d42939db7cd66c08fdcfe651eeaa1179714ee6 + sha256: "75e4b0ad0f8f63eed7935ff2506c809a670f5e6dd0f61304525879d53fc41a17" url: "https://pub.dev" source: hosted - version: "8.1.1" + version: "7.0.2" extended_image_library: dependency: transitive description: name: extended_image_library - sha256: "8bf87c0b14dcb59200c923a9a3952304e4732a0901e40811428834ef39018ee1" + sha256: "550743b43ab093aed35ef234500fcc7a304cbac1eca47b0cc991e07e88750758" + url: "https://pub.dev" + source: hosted + version: "3.4.2" + extended_masked_text: + dependency: "direct main" + description: + name: extended_masked_text + sha256: dba132fffa2b931e8cdd005e0509dfac359d3f98a175eca18c0ac71605247b6b url: "https://pub.dev" source: hosted - version: "3.6.0" + version: "2.3.1" fake_async: dependency: transitive description: @@ -454,10 +445,10 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: dcf54c170c5371ad0e79229d0fb372c58262ae0968b7de222bf28e51dd236be0 + sha256: "675c209c94a1817649137cbd113fc4c9ae85e48d03dd578629abbec6d8a4d93d" url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.16.0" firebase_core_platform_interface: dependency: transitive description: @@ -470,10 +461,26 @@ packages: dependency: transitive description: name: firebase_core_web - sha256: "4cf4d2161530332ddc3c562f19823fb897ff37a9a774090d28df99f47370e973" + sha256: e8c408923cd3a25bd342c576a114f2126769cd1a57106a4edeaa67ea4a84e962 + url: "https://pub.dev" + source: hosted + version: "2.8.0" + firebase_crashlytics: + dependency: transitive + description: + name: firebase_crashlytics + sha256: f4a4b046606e306b589bef5c1e268afbfab2e5fddde6de7e4340400465c8d231 url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "3.3.6" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: "8666b935e29b143297e2923dc8112663854f828d10954a92b8215e7249b55d59" + url: "https://pub.dev" + source: hosted + version: "3.6.6" firebase_in_app_messaging: dependency: "direct main" description: @@ -551,54 +558,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.0" - flutter_keyboard_visibility: - dependency: "direct main" - description: - name: flutter_keyboard_visibility - sha256: "4983655c26ab5b959252ee204c2fffa4afeb4413cd030455194ec0caa3b8e7cb" - url: "https://pub.dev" - source: hosted - version: "5.4.1" - flutter_keyboard_visibility_linux: - dependency: transitive - description: - name: flutter_keyboard_visibility_linux - sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - flutter_keyboard_visibility_macos: - dependency: transitive - description: - name: flutter_keyboard_visibility_macos - sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 - url: "https://pub.dev" - source: hosted - version: "1.0.0" - flutter_keyboard_visibility_platform_interface: - dependency: transitive - description: - name: flutter_keyboard_visibility_platform_interface - sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 - url: "https://pub.dev" - source: hosted - version: "2.0.0" - flutter_keyboard_visibility_web: - dependency: transitive - description: - name: flutter_keyboard_visibility_web - sha256: d3771a2e752880c79203f8d80658401d0c998e4183edca05a149f5098ce6e3d1 - url: "https://pub.dev" - source: hosted - version: "2.0.0" - flutter_keyboard_visibility_windows: - dependency: transitive - description: - name: flutter_keyboard_visibility_windows - sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 - url: "https://pub.dev" - source: hosted - version: "1.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -706,14 +665,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_windowmanager: - dependency: "direct main" - description: - name: flutter_windowmanager - sha256: b4d0bc06f6777952b729c0cdb7ce9ad1ecabd8b8b1cb0acb57a36621457dab1b - url: "https://pub.dev" - source: hosted - version: "0.2.0" font_awesome_flutter: dependency: "direct main" description: @@ -762,14 +713,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" - hive: - dependency: transitive - description: - name: hive - sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" - url: "https://pub.dev" - source: hosted - version: "2.2.3" html: dependency: "direct main" description: @@ -779,21 +722,29 @@ packages: source: hosted version: "0.15.4" http: - dependency: transitive + dependency: "direct main" description: name: http - sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "0.13.6" http_client_helper: dependency: transitive description: name: http_client_helper - sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1" + sha256: "14c6e756644339f561321dab021215475ba4779aa962466f59ccb3ecf66b36c3" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + http_interceptor: + dependency: "direct main" + description: + name: http_interceptor + sha256: "5f3dde028e67789339c250252c09510a74aff21ce16b06d07d9096bda6582bab" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "1.0.2" http_parser: dependency: transitive description: @@ -922,22 +873,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + in_app_update: + dependency: "direct main" + description: + name: in_app_update + sha256: b6ccb757281a96a4b18536f68fe2567aeca865134218719364212da8fe94615c + url: "https://pub.dev" + source: hosted + version: "4.2.2" intl: dependency: "direct main" description: name: intl - sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91" + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" url: "https://pub.dev" source: hosted - version: "0.17.0" + version: "0.18.1" js: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: "5528c2f391ededb7775ec1daa69e65a2d61276f7552de2b5f7b8d34ee9fd4ab7" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.6.5" json_annotation: dependency: "direct overridden" description: @@ -990,18 +949,18 @@ packages: dependency: transitive description: name: matcher - sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + sha256: "16db949ceee371e9b99d22f88fa3a73c4e59fd0afed0bd25fc336eb76c198b72" url: "https://pub.dev" source: hosted - version: "0.12.16" + version: "0.12.13" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.2.0" mdi: dependency: "direct main" description: @@ -1014,10 +973,10 @@ packages: dependency: transitive description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: "6c268b42ed578a53088d834796959e4a1814b5e9e164f147f580a386e5decf42" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.8.0" mime: dependency: transitive description: @@ -1062,10 +1021,10 @@ packages: dependency: transitive description: name: path - sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b url: "https://pub.dev" source: hosted - version: "1.8.3" + version: "1.8.2" path_drawing: dependency: transitive description: @@ -1282,30 +1241,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + screen_protector: + dependency: "direct main" + description: + name: screen_protector + sha256: "541bdcd341de1e38026b5b94cc2a74cd95299d2c51150735165c4b445fa0209a" + url: "https://pub.dev" + source: hosted + version: "1.4.2" screenshot: dependency: "direct main" description: name: screenshot - sha256: "455284ff1f5b911d94a43c25e1385485cf6b4f288293eba68f15dad711c7b81c" + sha256: "30bb9fade6eb2578a1fc2e84f6b184141fc86883cda10988d4500ff00eb728e2" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "1.3.0" sentry: dependency: transitive description: name: sentry - sha256: "830667eadc0398fea3a3424ed1b74568e2db603a42758d0922e2f2974ce55a60" + sha256: fd1fbfe860c05f5c52820ec4dbf2b6473789e83ead26cfc18bca4fe80bf3f008 url: "https://pub.dev" source: hosted - version: "7.10.1" + version: "8.2.0" sentry_flutter: dependency: "direct main" description: name: sentry_flutter - sha256: "6730f41b304c6fb0fa590dacccaf73ba11082fc64b274cfe8a79776f2b95309c" + sha256: c64f0aec5332bec87083b61514d1b6b29e435b9045d03ce1575861192b9a5680 url: "https://pub.dev" source: hosted - version: "7.10.1" + version: "8.2.0" share_plus: dependency: "direct main" description: @@ -1326,10 +1293,10 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: b7f41bad7e521d205998772545de63ff4e6c97714775902c199353f8bf1511ac + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" shared_preferences_android: dependency: transitive description: @@ -1403,10 +1370,10 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.9.1" sqflite: dependency: transitive description: @@ -1467,10 +1434,10 @@ packages: dependency: transitive description: name: test_api - sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + sha256: ad540f65f92caa91bf21dfc8ffb8c589d6e4dc0c2267818b4cc2792857706206 url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.4.16" tint: dependency: transitive description: @@ -1623,22 +1590,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - web: - dependency: transitive - description: - name: web - sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 - url: "https://pub.dev" - source: hosted - version: "0.1.4-beta" win32: dependency: transitive description: name: win32 - sha256: f2add6fa510d3ae152903412227bda57d0d5a8da61d2c39c1fb022c9429a41c0 + sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c" url: "https://pub.dev" source: hosted - version: "5.0.6" + version: "4.1.4" xdg_directories: dependency: transitive description: @@ -1664,5 +1623,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.1.0-185.0.dev <4.0.0" - flutter: ">=3.13.0" + dart: ">=2.19.0 <3.0.0" + flutter: ">=3.7.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2bef81c..e95d083 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,36 +1,28 @@ name: mi_utem -description: Plataforma académica para estudiantes de la Universidad Tecnológica - Metropolitana (UTEM) +description: 'Plataforma académica para estudiantes de la Universidad Tecnológica Metropolitana (UTEM)' publish_to: none -version: 2.11.2 +version: 3.0.0+131 environment: - sdk: ">=2.14.4 <3.0.0" + sdk: ">=2.17.0 <3.0.0" dependencies: awesome_notifications_core: ^0.8.1 - awesome_notifications: any - awesome_notifications_fcm: any + awesome_notifications: ^0.8.2 + awesome_notifications_fcm: ^0.8.0 badges: ^2.0.2 barcode_widget: ^2.0.3 barcode_image: ^2.0.2 - cached_network_image: ^3.2.0 carousel_slider: ^4.0.0 - charts_flutter: - git: - url: https://github.com/google/charts - path: charts_flutter + community_charts_flutter: ^1.0.2 circular_profile_avatar: ^2.0.5 clipboard: ^0.1.3 cloud_firestore: ^4.3.1 custom_refresh_indicator: ^2.0.1 - dio: ^4.0.4 - dio_cache_interceptor: ^3.2.6 - dio_cache_interceptor_hive_store: ^3.1.1 + dio: ^4.0.6 dotted_border: ^2.0.0+2 - extended_image: ^8.1.1 - firebase_core: 2.11.0 + firebase_core: 2.16.0 firebase_analytics: 10.2.1 firebase_in_app_messaging: ^0.7.0+10 firebase_remote_config: ^3.0.9 @@ -38,16 +30,13 @@ dependencies: flutter: sdk: flutter flutter_dotenv: ^5.0.2 - flutter_keyboard_visibility: ^5.0.3 flutter_markdown: ^0.6.1 flutter_masked_text: git: url: https://github.com/vitorhm/flutter-masked-text.git flutter_secure_storage: ^7.0.1 flutter_spinkit: ^5.1.0 - flutter_windowmanager: ^0.2.0 font_awesome_flutter: ^10.0.0 - get: ^4.1.4 gradient_widgets: ^0.6.0 html: ^0.15.0 image_editor: ^1.0.0 @@ -56,26 +45,35 @@ dependencies: logger: ^1.3.0 mdi: ^5.0.0-nullsafety.0 package_info_plus: ^4.2.0 - path_provider: ^2.0.2 + path_provider: ^2.1.1 permission_handler: ^10.2.0 photo_view: ^0.14.0 qr_flutter: ^4.0.0 recase: ^4.0.0 responsive_framework: ^0.2.0 - screenshot: ^2.1.0 - sentry_flutter: ^7.10.1 + sentry_flutter: ^8.2.0 share_plus: ^7.2.1 simple_gesture_detector: ^0.2.0 url_launcher: ^6.1.7 video_player: ^2.6.1 - dio_http_cache: ^0.3.0 image: ^3.1.0 - intl: ^0.17.0 + intl: ^0.18.1 get_storage: ^2.0.3 hexcolor: ^3.0.1 - shared_preferences: ^2.0.20 + shared_preferences: ^2.2.2 background_fetch: ^1.1.5 flutter_uxcam: ^2.3.0 + in_app_update: ^4.2.2 + http: ^0.13.6 + http_interceptor: ^1.0.2 + screenshot: ^1.3.0 + extended_image: ^7.0.2 + cached_network_image: ^3.2.3 + extended_masked_text: ^2.3.1 + crypto: ^3.0.3 + get: ^4.6.5 + dio_http_cache: ^0.3.0 + screen_protector: ^1.4.2 dependency_overrides: qr: ^3.0.0 @@ -100,4 +98,5 @@ flutter: - assets/animations/monito.flr - assets/animations/utem.flr - assets/images/sibutem.jpg + - assets/launcher_icons/prod/full_icon.png - .env diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..8f38974 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,27 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + + + +// void main() { +// testWidgets('Counter increments smoke test', (WidgetTester tester) async { +// // Build our app and trigger a frame. +// await tester.pumpWidget(const MyApp()); +// +// // Verify that our counter starts at 0. +// expect(find.text('0'), findsOneWidget); +// expect(find.text('1'), findsNothing); +// +// // Tap the '+' icon and trigger a frame. +// await tester.tap(find.byIcon(Icons.add)); +// await tester.pump(); +// +// // Verify that our counter has incremented. +// expect(find.text('0'), findsNothing); +// expect(find.text('1'), findsOneWidget); +// }); +// }