diff --git a/.github/workflows/build-bump.yml b/.github/workflows/build-bump.yml
new file mode 100644
index 0000000..d27d01d
--- /dev/null
+++ b/.github/workflows/build-bump.yml
@@ -0,0 +1,29 @@
+name: Número de compilación
+on:
+ pull_request:
+ types: [opened]
+ branches: [dev]
+
+permissions:
+ contents: write
+
+jobs:
+ incrementar:
+ if: github.event.pull_request.user.login != 'dependabot[bot]'
+ runs-on: ubuntu-latest
+ steps:
+ - name: 📚 Clonar Repositorio
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+ repository: ${{ github.event.pull_request.head.repo.full_name }}
+
+ - name: 🔢 Incrementar número de compilación
+ run: perl -i -pe 's/^(version:\s*\d+\.\d+\.\d+\+)(\d+)\s*$/$1.($2+1)."\n"/e' pubspec.yaml
+
+ - name: 📤 Publicar cambios
+ run: |
+ git config user.name 'github-actions[bot]'
+ git config user.email 'github-actions[bot]@users.noreply.github.com'
+ git commit -am 'chore: incrementa número de compilación'
+ git push
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index efc4883..df359eb 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -7,11 +7,11 @@ on:
track:
description: "Selecciona el track de despliegue"
required: true
- default: "development"
+ default: "dev"
type: choice
options:
- - production
- - development
+ - prod
+ - dev
jobs:
determine-environment:
# Los bumps de dependabot no despliegan. Al saltarse este job, los deploy-* que dependen de él también se saltan.
@@ -223,6 +223,218 @@ jobs:
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
+ # Las capturas van en jobs aparte y no dentro de deploy-*: corren en paralelo con el
+ # despliegue, no tocan la firma del binario que sí se publica y, si fallan, el release
+ # ya salió igual. Además necesitan otra configuración de firebase (Debug y los dos
+ # entornos, porque `flutter drive` compila en debug y el test importa ambas opciones).
+ screenshots-ios:
+ needs: determine-environment
+ runs-on: macos-latest
+ environment: ${{ needs.determine-environment.outputs.environment }}
+ timeout-minutes: 60
+ # Si contiene [skip deploy] o [skip screenshots] en el mensaje del commit, se saltará.
+ if: ${{ !contains(github.event.head_commit.message, '[skip deploy]') && !contains(github.event.head_commit.message, '[skip screenshots]') }}
+ steps:
+ - name: 📚 Clonar Repositorio
+ uses: actions/checkout@v4
+
+ - name: ⚒️ Configurar Xcode
+ uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: ${{ vars.XCODE_VERSION }}
+
+ - name: ⚡ Configurar Cache para Flutter pub
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.pub-cache
+ key: pub-cache-screenshots-ios-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }}
+ restore-keys: pub-cache-screenshots-ios-${{ runner.os }}-
+
+ - name: ⚡ Configurar Cache para CocoaPods
+ uses: actions/cache@v5
+ with:
+ path: |
+ ios/Pods
+ ~/.cocoapods
+ key: pods-screenshots-ios-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
+ restore-keys: pods-screenshots-ios-${{ runner.os }}-
+
+ - name: 🪄 Instalar ImageMagick
+ # frameit y los scripts de fondos y composición trabajan con `magick`.
+ run: brew install imagemagick
+
+ - name: 🐦 Configurar Flutter SDK
+ uses: flutter-actions/setup-flutter@v4
+ with:
+ cache: true
+ channel: ${{ vars.FLUTTER_CHANNEL }}
+ version: ${{ vars.FLUTTER_VERSION }}
+
+ - name: 💎 Configurar Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ vars.RUBY_VERSION }}
+ bundler-cache: true
+
+ - name: 🗳️ Instalar firebase-tools
+ run: npm install -g firebase-tools
+
+ - name: 📦 Instalar librerías
+ run: flutter pub get
+
+ - name: 🔥 Instalar FlutterFire
+ run: dart pub global activate flutterfire_cli
+
+ # flutterfire configure invoca `ruby -e "require 'xcodeproj'"` fuera de bundler, así
+ # que la gem tiene que estar instalada global (igual que en deploy-ios y deploy-macos).
+ - name: 💍 Instala gem xcodeproj
+ run: sudo gem install xcodeproj
+
+ - name: Configura bundle
+ run: bundle install
+
+ - name: 🔐 Configura credenciales de firebase
+ env:
+ FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
+ # `all` y Debug: `flutter drive` compila en debug y integration_test/screenshots_test.dart
+ # importa las opciones de firebase de los dos entornos.
+ run: ./scripts/flutterfire-configure all --build-type=Debug
+
+ - name: 📸 Generar capturas de pantalla
+ run: bundle exec fastlane ios screenshots
+ env:
+ APP_ENV: ${{ vars.APP_ENV }}
+
+ - name: 📤 Subir capturas como artefacto
+ id: artefacto
+ uses: actions/upload-artifact@v7
+ with:
+ name: capturas-ios
+ path: fastlane/screenshots/*/*.png
+ if-no-files-found: error
+
+ - name: 📝 Publicar capturas en el resumen
+ env:
+ ARTEFACTO_URL: ${{ steps.artefacto.outputs.artifact-url }}
+ run: |
+ {
+ echo "## 📸 Capturas de pantalla (iOS)"
+ echo ""
+ echo "- $(find fastlane/screenshots -name '*_framed.png' | wc -l | tr -d ' ') capturas enmarcadas para la App Store"
+ echo "- [Descargar artefacto]($ARTEFACTO_URL)"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ screenshots-android:
+ needs: determine-environment
+ runs-on: ubuntu-latest
+ environment: ${{ needs.determine-environment.outputs.environment }}
+ timeout-minutes: 60
+ # Igual que deploy-android, sólo producción: las capturas son las de la ficha de Play.
+ if: ${{ needs.determine-environment.outputs.environment == 'prod' && (!contains(github.event.head_commit.message, '[skip deploy]') && !contains(github.event.head_commit.message, '[skip screenshots]')) }}
+ steps:
+ - name: 📚 Clonar Repositorio
+ uses: actions/checkout@v4
+
+ - name: 🖥️ Habilitar KVM
+ # Sin esto el emulador arranca por software y el recorrido no alcanza a terminar.
+ run: |
+ echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
+ sudo udevadm control --reload-rules
+ sudo udevadm trigger --name-match=kvm
+
+ - name: ⚡ Configurar Cache para Gradle
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: gradle-screenshots-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
+ restore-keys: gradle-screenshots-${{ runner.os }}-
+
+ - name: ⚡ Configurar Cache para Flutter pub
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.pub-cache
+ key: pub-cache-screenshots-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }}
+ restore-keys: pub-cache-screenshots-${{ runner.os }}-
+
+ - name: 🪄 Instalar ImageMagick
+ # frameit y los scripts de fondos y composición trabajan con `magick`, que sólo
+ # existe en ImageMagick 7: el paquete de apt es el 6 y deja únicamente `convert`.
+ # El binario oficial es un AppImage, así que también hace falta FUSE.
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libfuse2t64 || sudo apt-get install -y libfuse2
+ sudo curl -fsSL -o /usr/local/bin/magick https://imagemagick.org/archive/binaries/magick
+ sudo chmod +x /usr/local/bin/magick
+ magick -version
+
+ - name: 🐦 Configurar Flutter SDK
+ uses: flutter-actions/setup-flutter@v4
+ with:
+ cache: true
+ channel: ${{ vars.FLUTTER_CHANNEL }}
+ version: ${{ vars.FLUTTER_VERSION }}
+
+ - name: 💎 Configurar Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ vars.RUBY_VERSION }}
+ bundler-cache: true
+
+ - name: 🗳️ Instalar firebase-tools
+ run: npm install -g firebase-tools
+
+ - name: 📦 Instalar librerías
+ run: flutter pub get
+
+ - name: 🔥 Instalar FlutterFire
+ run: dart pub global activate flutterfire_cli
+
+ - name: Configura bundle
+ run: bundle install
+
+ - name: 🔐 Configura credenciales de firebase
+ env:
+ FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
+ run: ./scripts/flutterfire-configure all --build-type=Debug
+
+ - name: 📸 Generar capturas de pantalla
+ # El lane reutiliza el emulador que deja levantado la acción en vez de arrancar uno.
+ uses: reactivecircus/android-emulator-runner@v2
+ env:
+ APP_ENV: ${{ vars.APP_ENV }}
+ APP_PACKAGE_NAME: ${{ secrets.APP_PACKAGE_NAME }}
+ with:
+ api-level: 35
+ target: google_apis
+ arch: x86_64
+ profile: pixel_6
+ disable-animations: true
+ emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
+ script: bundle exec fastlane android screenshots
+
+ - name: 📤 Subir capturas como artefacto
+ id: artefacto
+ uses: actions/upload-artifact@v7
+ with:
+ name: capturas-android
+ path: android/fastlane/screenshots/*/*.png
+ if-no-files-found: error
+
+ - name: 📝 Publicar capturas en el resumen
+ env:
+ ARTEFACTO_URL: ${{ steps.artefacto.outputs.artifact-url }}
+ run: |
+ {
+ echo "## 📸 Capturas de pantalla (Android)"
+ echo ""
+ echo "- $(find android/fastlane/screenshots -name '*_framed.png' | wc -l | tr -d ' ') capturas enmarcadas para Google Play"
+ echo "- [Descargar artefacto]($ARTEFACTO_URL)"
+ } >> "$GITHUB_STEP_SUMMARY"
+
deploy-macos:
needs: determine-environment
runs-on: macos-latest
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 73d7cfb..a001e39 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -9,6 +9,9 @@ analyzer:
- lib/firebase_options_*.dart
- lib/main_dev.dart
- lib/main_prod.dart
+ # Importa las opciones de firebase de los dos entornos, que las genera
+ # `scripts/flutterfire-configure` y no están versionadas.
+ - integration_test/screenshots_test.dart
- fastlane/build/**
- build/**
- android/**
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
index 399f698..4fa8d35 100644
--- a/android/app/src/debug/AndroidManifest.xml
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -4,4 +4,15 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/fastlane/.gitignore b/android/fastlane/.gitignore
new file mode 100644
index 0000000..3f5cf9e
--- /dev/null
+++ b/android/fastlane/.gitignore
@@ -0,0 +1,3 @@
+# Las capturas se generan con `fastlane android screenshots`; la configuración de frameit sí se versiona.
+metadata/
+screenshots/**/*.png
diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile
index 19d79b5..0500b41 100644
--- a/android/fastlane/Fastfile
+++ b/android/fastlane/Fastfile
@@ -1,5 +1,49 @@
import "../fastlane/src/helpers.rb"
+require "timeout"
+
+# Raíz del SDK de Android. `adb` y `emulator` casi nunca están en el PATH de un lane.
+def android_sdk_root
+ root = ENV["ANDROID_HOME"] || ENV["ANDROID_SDK_ROOT"] || File.expand_path("~/Library/Android/sdk")
+ UI.user_error!("❌ No se encontró el SDK de Android. Define ANDROID_HOME.") unless Dir.exist?(root)
+
+ root
+end
+
+def adb_bin
+ File.join(android_sdk_root, "platform-tools", "adb")
+end
+
+def emulator_bin
+ File.join(android_sdk_root, "emulator", "emulator")
+end
+
+# Serial del primer dispositivo o emulador listo, o nil si no hay ninguno.
+def android_connected_device
+ `#{adb_bin} devices`.lines.map(&:strip).find { |linea| linea.end_with?("\tdevice") }&.split("\t")&.first
+end
+
+# Arranca el AVD indicado (o el primero disponible) y espera a que termine de bootear.
+def android_boot_emulator(avd)
+ avds = `#{emulator_bin} -list-avds`.split("\n").map(&:strip).reject(&:empty?)
+ UI.user_error!("❌ No hay ningún AVD creado. Crea uno desde Android Studio.") if avds.empty?
+
+ avd = avds.first if avd.to_s.empty?
+ UI.user_error!("❌ No existe el AVD '#{avd}'. Disponibles: #{avds.join(', ')}") unless avds.include?(avd)
+
+ UI.message("📱 Arrancando el emulador #{avd}...")
+ Process.spawn(emulator_bin, "-avd", avd, "-no-snapshot-save", "-no-boot-anim", %i[out err] => File::NULL)
+
+ Timeout.timeout(600) do
+ sleep(3) until (serial = android_connected_device)
+ sleep(3) until `#{adb_bin} -s #{serial} shell getprop sys.boot_completed`.strip == "1"
+ end
+
+ android_connected_device
+rescue Timeout::Error
+ UI.user_error!("❌ El emulador #{avd} no terminó de arrancar.")
+end
+
platform :android do
desc "Decodifica el keystore desde base64 y lo deja en un archivo temporal"
lane :setup_keystore do |options|
@@ -48,6 +92,131 @@ platform :android do
sh(*command)
end
+ desc "Genera las capturas de pantalla en un emulador y las deja en android/fastlane/metadata"
+ lane :screenshots do |options|
+ avd = string_option(options, :avd, env_var: "SCREENSHOT_AVD", default: "")
+ locale = string_option(options, :locale, env_var: "PLAY_SCREENSHOT_LOCALE", default: "es-419")
+ app_env = string_option(options, :app_env, env_var: "APP_ENV", default: "prod")
+ clear = bool_option(options, :clear, env_var: "SCREENSHOT_CLEAR", default: true)
+ package_name = string_option(options, :package_name, env_var: "APP_PACKAGE_NAME", required: true)
+ # Google Play rechaza las capturas cuyo lado mayor supere el doble del menor, y los
+ # teléfonos actuales son más alargados que eso (un Pixel 9a da 1080x2424 = 2,24:1).
+ # 1080x2160 es el máximo que acepta y el mínimo alto en que la credencial entra completa.
+ size = string_option(options, :size, env_var: "SCREENSHOT_ANDROID_SIZE", default: "1080x2160")
+
+ frame = bool_option(options, :frame, env_var: "SCREENSHOT_FRAME", default: true)
+ # Vacío deja que frameit elija el marco según el tamaño de la captura. Si se fija uno,
+ # tiene que ser de la lista que acepta frameit (Google Pixel 3, 4, 5, Samsung Galaxy S10...).
+ device_frame = string_option(options, :device_frame, env_var: "SCREENSHOT_ANDROID_FRAME", default: "")
+
+ scheme = app_env == "prod" ? "production" : "development"
+ package_name = "#{package_name}.dev" unless scheme == "production"
+ # APP_ENV suele quedar en dev en el .env local, y las capturas de la tienda tienen que
+ # salir del flavor de producción (nombre, ícono y package distintos).
+ UI.important("⚠️ Capturando el flavor #{scheme}. Para Google Play usa `app_env:prod`.") unless app_env == "prod"
+ # Ruta absoluta: el CWD de un lane es la carpeta fastlane/, no la raíz del repo.
+ root = sh("git", "rev-parse", "--show-toplevel", log: false).strip
+ # Las capturas se toman acá, junto a la configuración de frameit, y sólo las finales
+ # se copian a metadata/: supply sube todas las imágenes de esa carpeta y Play acepta 8.
+ screenshots_dir = File.join(root, "android", "fastlane", "screenshots")
+ output_dir = File.join(screenshots_dir, locale)
+ play_dir = File.join(root, "android", "fastlane", "metadata", "android", locale, "images", "phoneScreenshots")
+
+ serial = android_connected_device
+ started_here = serial.nil?
+ serial = android_boot_emulator(avd) if started_here
+ UI.message("📱 Usando #{serial}")
+
+ # Una instalación previa firmada con otra llave deja a `flutter drive` sin poder
+ # lanzar la actividad, con un error que no dice que el problema es ese.
+ [package_name, "#{package_name}.test"].each { |pkg| `#{adb_bin} -s #{serial} uninstall #{pkg}` }
+
+ # Google Play sólo acepta 8 capturas por dispositivo: si quedan las anteriores, falla el upload.
+ # Se borran sólo las imágenes: en la carpeta del idioma también vive el title.strings de frameit.
+ if clear
+ Dir.glob(File.join(output_dir, "*.png")).each { |captura| File.delete(captura) }
+ FileUtils.rm_rf(play_dir)
+ end
+ FileUtils.mkdir_p(output_dir)
+
+ begin
+ unless size.to_s.empty?
+ sh(adb_bin, "-s", serial, "shell", "wm", "size", size)
+ sleep(5) # La UI del sistema se reinicia al cambiar la resolución.
+ end
+
+ ENV["SCREENSHOTS_DIR"] = output_dir
+ ENV["SCREENSHOT_PREFIX"] = ""
+ # El APK de las capturas es debug y no se distribuye: se firma con la llave de debug
+ # para no depender del keystore de release que traiga el .env.
+ ENV["MIUTEM_USE_DEBUG_SIGNING"] = "true"
+
+ # SCREENSHOT_MODE hace que la app use los datos ficticios de lib/core/mock/
+ # en vez de conectarse a SIGA y Mi.UTEM: no se necesita ninguna cuenta real.
+ sh(
+ "flutter", "drive",
+ "--driver=test_driver/integration_test.dart",
+ "--target=integration_test/screenshots_test.dart",
+ "--flavor", scheme,
+ "-d", serial,
+ "--dart-define=SCREENSHOT_MODE=true",
+ "--dart-define=SCREENSHOT_PROD=#{app_env == 'prod'}"
+ )
+ ensure
+ # El emulador puede ser uno que ya tenía abierto quien ejecuta el lane.
+ `#{adb_bin} -s #{serial} shell wm size reset` unless size.to_s.empty?
+ `#{adb_bin} -s #{serial} emu kill` if started_here
+ end
+
+ # Un timeout del test igual reporta "All tests passed" sin escribir nada, así que se verifica el resultado.
+ capturas = Dir.glob(File.join(output_dir, "*.png"))
+ UI.user_error!("❌ No se generó ninguna captura. Revisa el log de `flutter drive`.") if capturas.empty?
+
+ if frame
+ # Los fondos se cortan al tamaño exacto de las capturas: si cambia la resolución del
+ # emulador, frameit recortaría las tiras y rompería la continuidad entre capturas.
+ sh(File.join(root, "scripts", "generate-screenshot-backgrounds"), "android")
+
+ opciones_frameit = { path: screenshots_dir, use_platform: "ANDROID" }
+ opciones_frameit[:force_device_type] = device_frame unless device_frame.to_s.empty?
+ frame_screenshots(**opciones_frameit)
+ # Las dos primeras capturas no salen de la app: se componen aparte, y después de
+ # frameit porque escriben directamente sus *_framed.png.
+ sh(File.join(root, "scripts", "compose-store-screenshots"), "android")
+ capturas = Dir.glob(File.join(output_dir, "*_framed.png"))
+ UI.user_error!("❌ frameit no generó ninguna captura enmarcada.") if capturas.empty?
+ end
+
+ FileUtils.mkdir_p(play_dir)
+ capturas.each { |captura| FileUtils.cp(captura, play_dir) }
+
+ UI.success("✅ #{capturas.length} capturas listas en #{play_dir}")
+ end
+
+ desc "Sube a Google Play las capturas y las imágenes de la ficha (sin tocar el binario)"
+ lane :upload_screenshots do |options|
+ json_key_data = string_option(options, :json_key_data, env_var: "GOOGLE_PLAY_JSON_KEY", required: true)
+ package_name = string_option(options, :package_name, env_var: "APP_PACKAGE_NAME", required: true)
+
+ metadata_path = File.join(sh("git", "rev-parse", "--show-toplevel", log: false).strip,
+ "android", "fastlane", "metadata", "android")
+
+ upload_to_play_store(
+ package_name: package_name,
+ json_key_data: json_key_data,
+ metadata_path: metadata_path,
+ skip_upload_aab: true,
+ skip_upload_apk: true,
+ # Los textos de la ficha se editan en Play Console, acá sólo van las imágenes.
+ skip_upload_metadata: true,
+ skip_upload_changelogs: true,
+ skip_upload_images: false,
+ skip_upload_screenshots: false,
+ )
+
+ UI.success("✅ Capturas subidas a Google Play")
+ end
+
desc "Sube el AAB a Google Play"
lane :upload do |options|
app_env = string_option(options, :app_env, env_var: "APP_ENV", default: "dev")
diff --git a/android/fastlane/metadata/android/es-419/images/phoneScreenshots/1_es-419.jpeg b/android/fastlane/metadata/android/es-419/images/phoneScreenshots/1_es-419.jpeg
deleted file mode 100644
index fb9c68e..0000000
Binary files a/android/fastlane/metadata/android/es-419/images/phoneScreenshots/1_es-419.jpeg and /dev/null differ
diff --git a/android/fastlane/metadata/android/es-419/images/phoneScreenshots/2_es-419.jpeg b/android/fastlane/metadata/android/es-419/images/phoneScreenshots/2_es-419.jpeg
deleted file mode 100644
index 23cc129..0000000
Binary files a/android/fastlane/metadata/android/es-419/images/phoneScreenshots/2_es-419.jpeg and /dev/null differ
diff --git a/android/fastlane/metadata/android/es-419/images/phoneScreenshots/3_es-419.jpeg b/android/fastlane/metadata/android/es-419/images/phoneScreenshots/3_es-419.jpeg
deleted file mode 100644
index cfdd7ae..0000000
Binary files a/android/fastlane/metadata/android/es-419/images/phoneScreenshots/3_es-419.jpeg and /dev/null differ
diff --git a/android/fastlane/metadata/android/es-419/images/phoneScreenshots/4_es-419.jpeg b/android/fastlane/metadata/android/es-419/images/phoneScreenshots/4_es-419.jpeg
deleted file mode 100644
index d999c37..0000000
Binary files a/android/fastlane/metadata/android/es-419/images/phoneScreenshots/4_es-419.jpeg and /dev/null differ
diff --git a/android/fastlane/metadata/android/es-419/images/phoneScreenshots/5_es-419.jpeg b/android/fastlane/metadata/android/es-419/images/phoneScreenshots/5_es-419.jpeg
deleted file mode 100644
index 386bc34..0000000
Binary files a/android/fastlane/metadata/android/es-419/images/phoneScreenshots/5_es-419.jpeg and /dev/null differ
diff --git a/android/fastlane/screenshots/Framefile.json b/android/fastlane/screenshots/Framefile.json
new file mode 100644
index 0000000..cdf9b07
--- /dev/null
+++ b/android/fastlane/screenshots/Framefile.json
@@ -0,0 +1,56 @@
+{
+ "device_frame_version": "latest",
+ "default": {
+ "background": "./backgrounds/05_inicio.jpg",
+ "padding": 50,
+ "title_below_image": false,
+ "show_complete_frame": false,
+ "stack_title": true,
+ "use_platform": "ANDROID",
+ "keyword": {
+ "font": "../../../fastlane/screenshots/fonts/Roboto.ttf",
+ "color": "#FFFFFF",
+ "font_size": 68
+ },
+ "title": {
+ "font": "../../../fastlane/screenshots/fonts/Roboto.ttf",
+ "color": "#CFE8DC",
+ "font_size": 40
+ },
+ "title_min_height": 508
+ },
+ "data": [
+ {
+ "filter": "01_bienvenida",
+ "background": "./backgrounds/01_bienvenida.jpg"
+ },
+ {
+ "filter": "02_bienvenida",
+ "background": "./backgrounds/02_bienvenida.jpg"
+ },
+ {
+ "filter": "03_oscuro",
+ "background": "./backgrounds/03_oscuro.jpg"
+ },
+ {
+ "filter": "04_calculadora",
+ "background": "./backgrounds/04_calculadora.jpg"
+ },
+ {
+ "filter": "05_inicio",
+ "background": "./backgrounds/05_inicio.jpg"
+ },
+ {
+ "filter": "06_credencial",
+ "background": "./backgrounds/06_credencial.jpg"
+ },
+ {
+ "filter": "07_horario",
+ "background": "./backgrounds/07_horario.jpg"
+ },
+ {
+ "filter": "08_malla",
+ "background": "./backgrounds/08_malla.jpg"
+ }
+ ]
+}
diff --git a/android/fastlane/screenshots/background.jpg b/android/fastlane/screenshots/background.jpg
new file mode 100644
index 0000000..e6b8ff8
Binary files /dev/null and b/android/fastlane/screenshots/background.jpg differ
diff --git a/android/fastlane/screenshots/backgrounds/01_bienvenida.jpg b/android/fastlane/screenshots/backgrounds/01_bienvenida.jpg
new file mode 100644
index 0000000..e77acc8
Binary files /dev/null and b/android/fastlane/screenshots/backgrounds/01_bienvenida.jpg differ
diff --git a/android/fastlane/screenshots/backgrounds/02_bienvenida.jpg b/android/fastlane/screenshots/backgrounds/02_bienvenida.jpg
new file mode 100644
index 0000000..8243073
Binary files /dev/null and b/android/fastlane/screenshots/backgrounds/02_bienvenida.jpg differ
diff --git a/android/fastlane/screenshots/backgrounds/03_oscuro.jpg b/android/fastlane/screenshots/backgrounds/03_oscuro.jpg
new file mode 100644
index 0000000..8bb9018
Binary files /dev/null and b/android/fastlane/screenshots/backgrounds/03_oscuro.jpg differ
diff --git a/android/fastlane/screenshots/backgrounds/04_calculadora.jpg b/android/fastlane/screenshots/backgrounds/04_calculadora.jpg
new file mode 100644
index 0000000..69f547f
Binary files /dev/null and b/android/fastlane/screenshots/backgrounds/04_calculadora.jpg differ
diff --git a/android/fastlane/screenshots/backgrounds/05_inicio.jpg b/android/fastlane/screenshots/backgrounds/05_inicio.jpg
new file mode 100644
index 0000000..ebdecfd
Binary files /dev/null and b/android/fastlane/screenshots/backgrounds/05_inicio.jpg differ
diff --git a/android/fastlane/screenshots/backgrounds/06_credencial.jpg b/android/fastlane/screenshots/backgrounds/06_credencial.jpg
new file mode 100644
index 0000000..c7bbaed
Binary files /dev/null and b/android/fastlane/screenshots/backgrounds/06_credencial.jpg differ
diff --git a/android/fastlane/screenshots/backgrounds/07_horario.jpg b/android/fastlane/screenshots/backgrounds/07_horario.jpg
new file mode 100644
index 0000000..ae58dfb
Binary files /dev/null and b/android/fastlane/screenshots/backgrounds/07_horario.jpg differ
diff --git a/android/fastlane/screenshots/backgrounds/08_malla.jpg b/android/fastlane/screenshots/backgrounds/08_malla.jpg
new file mode 100644
index 0000000..61253c4
Binary files /dev/null and b/android/fastlane/screenshots/backgrounds/08_malla.jpg differ
diff --git a/android/fastlane/screenshots/es-419/keyword.strings b/android/fastlane/screenshots/es-419/keyword.strings
new file mode 100644
index 0000000..96a305d
--- /dev/null
+++ b/android/fastlane/screenshots/es-419/keyword.strings
@@ -0,0 +1,12 @@
+/* Titular de cada captura: la línea grande de arriba. El subtítulo va en title.strings.
+ Los saltos de línea se escriben \n y hay que ponerlos a mano: frameit no corta el texto,
+ sólo lo achica hasta que quepa a lo ancho. Las claves 01 y 02 las usa
+ scripts/compose-store-screenshots, que arma esas dos capturas fuera de frameit. */
+"01_bienvenida" = "";
+"02_bienvenida" = "La aplicación móvil\noficial de la UTEM";
+"03_oscuro" = "Modo Oscuro";
+"04_calculadora" = "Nueva Calculadora";
+"05_inicio" = "Pantalla Principal";
+"06_credencial" = "Ten siempre a mano\ntu Credencial";
+"07_horario" = "Un horario más dinámico";
+"08_malla" = "Revisa tu malla";
diff --git a/android/fastlane/screenshots/es-419/title.strings b/android/fastlane/screenshots/es-419/title.strings
new file mode 100644
index 0000000..cc185d8
--- /dev/null
+++ b/android/fastlane/screenshots/es-419/title.strings
@@ -0,0 +1,9 @@
+/* Subtítulo de cada captura: la línea chica bajo el titular de keyword.strings. */
+"01_bienvenida" = "";
+"02_bienvenida" = "Podrás revisar toda tu información\nacadémica en un solo lugar";
+"03_oscuro" = "Ahora tienes disponible el tan\nsolicitado modo oscuro";
+"04_calculadora" = "Igual de útil, y un nuevo diseño";
+"05_inicio" = "Ahora encuentras tus clases\nde hoy al instante";
+"06_credencial" = "Compatible con SIBUTEM";
+"07_horario" = "Consulta cuál es tu siguiente clase\nen un colorido horario";
+"08_malla" = "Consulta tu avance de malla\nen un formato más cómodo";
diff --git a/assets/images/utem_logo_color_azul.png b/assets/images/utem_logo_color_azul.png
index aa010d6..7b744ca 100644
Binary files a/assets/images/utem_logo_color_azul.png and b/assets/images/utem_logo_color_azul.png differ
diff --git a/assets/images/utem_logo_color_blanco.png b/assets/images/utem_logo_color_blanco.png
index 6c3bbb7..0f309c7 100644
Binary files a/assets/images/utem_logo_color_blanco.png and b/assets/images/utem_logo_color_blanco.png differ
diff --git a/assets/images/utem_logo_negativo.png b/assets/images/utem_logo_negativo.png
index 8416484..e4618d6 100644
Binary files a/assets/images/utem_logo_negativo.png and b/assets/images/utem_logo_negativo.png differ
diff --git a/assets/mock/alex-suprun-unsplash.jpg b/assets/mock/alex-suprun-unsplash.jpg
new file mode 100644
index 0000000..5781ce5
Binary files /dev/null and b/assets/mock/alex-suprun-unsplash.jpg differ
diff --git a/fastlane/.env.example b/fastlane/.env.example
index ec49055..8db08dc 100644
--- a/fastlane/.env.example
+++ b/fastlane/.env.example
@@ -33,6 +33,23 @@ MATCH_GIT_BASIC_AUTHORIZATION=
# Tipo de perfil para iOS/macOS (appstore, development, ad-hoc, enterprise)
MATCH_TYPE="appstore"
+# Capturas de pantalla automatizadas (lane `ios screenshots`)
+# Simuladores a usar, separados por coma. El tamaño de 6.9" (iPhone 17/16 Pro Max) es el único obligatorio en la App Store.
+SCREENSHOT_DEVICES="iPhone 17 Pro Max"
+# Idioma de la ficha en App Store Connect. Define la subcarpeta en fastlane/screenshots/
+SCREENSHOT_LOCALE=es-MX
+# No se necesita ninguna cuenta: la app corre con los datos ficticios de lib/core/mock/
+# Envolver las capturas con frameit (marco del dispositivo, fondo y título). Requiere imagemagick.
+SCREENSHOT_FRAME=true
+
+# Capturas de pantalla automatizadas (lane `android screenshots`)
+# AVD a usar. Vacío = el emulador que ya esté abierto, o el primer AVD disponible.
+SCREENSHOT_AVD=
+# Idioma de la ficha en Google Play. Define la subcarpeta en android/fastlane/metadata/android/
+PLAY_SCREENSHOT_LOCALE=es-419
+# Marco de frameit para Android. Vacío = lo elige según el tamaño de la captura.
+SCREENSHOT_ANDROID_FRAME=
+
# App Store Connect API Key configuration for iOS app distribution
APP_STORE_CONNECT_API_KEY_ID=
APP_STORE_CONNECT_ISSUER_ID=
diff --git a/fastlane/.gitignore b/fastlane/.gitignore
index 95e6c86..6080c65 100644
--- a/fastlane/.gitignore
+++ b/fastlane/.gitignore
@@ -1,2 +1,4 @@
build/
-*.xml
\ No newline at end of file
+# Las capturas se generan con `fastlane ios screenshots`; la configuración de frameit sí se versiona.
+screenshots/**/*.png
+*.xml
diff --git a/fastlane/README.md b/fastlane/README.md
index a2b94b2..34a9b2e 100644
--- a/fastlane/README.md
+++ b/fastlane/README.md
@@ -63,6 +63,22 @@ Compila la app con flutter para iOS
Construye y firma la app para distribución en iOS
+### ios screenshots
+
+```sh
+[bundle exec] fastlane ios screenshots
+```
+
+Genera las capturas de pantalla de iPhone en el simulador y las guarda en fastlane/screenshots
+
+### ios upload_screenshots
+
+```sh
+[bundle exec] fastlane ios upload_screenshots
+```
+
+Sube a App Store Connect las capturas ya generadas (sin tocar el binario)
+
### ios load_api_key
```sh
@@ -108,6 +124,22 @@ Decodifica el keystore desde base64 y lo deja en un archivo temporal
Compila la app con flutter
+### android screenshots
+
+```sh
+[bundle exec] fastlane android screenshots
+```
+
+Genera las capturas de pantalla en un emulador y las deja en android/fastlane/metadata
+
+### android upload_screenshots
+
+```sh
+[bundle exec] fastlane android upload_screenshots
+```
+
+Sube a Google Play las capturas y las imágenes de la ficha (sin tocar el binario)
+
### android upload
```sh
diff --git a/fastlane/screenshots/Framefile.json b/fastlane/screenshots/Framefile.json
new file mode 100644
index 0000000..2f24d3d
--- /dev/null
+++ b/fastlane/screenshots/Framefile.json
@@ -0,0 +1,55 @@
+{
+ "device_frame_version": "latest",
+ "default": {
+ "background": "./backgrounds/05_inicio.jpg",
+ "padding": 60,
+ "title_below_image": false,
+ "show_complete_frame": false,
+ "stack_title": true,
+ "keyword": {
+ "font": "./fonts/Roboto.ttf",
+ "color": "#FFFFFF",
+ "font_size": 84
+ },
+ "title": {
+ "font": "./fonts/Roboto.ttf",
+ "color": "#CFE8DC",
+ "font_size": 48
+ },
+ "title_min_height": 620
+ },
+ "data": [
+ {
+ "filter": "01_bienvenida",
+ "background": "./backgrounds/01_bienvenida.jpg"
+ },
+ {
+ "filter": "02_bienvenida",
+ "background": "./backgrounds/02_bienvenida.jpg"
+ },
+ {
+ "filter": "03_oscuro",
+ "background": "./backgrounds/03_oscuro.jpg"
+ },
+ {
+ "filter": "04_calculadora",
+ "background": "./backgrounds/04_calculadora.jpg"
+ },
+ {
+ "filter": "05_inicio",
+ "background": "./backgrounds/05_inicio.jpg"
+ },
+ {
+ "filter": "06_credencial",
+ "background": "./backgrounds/06_credencial.jpg"
+ },
+ {
+ "filter": "07_horario",
+ "background": "./backgrounds/07_horario.jpg"
+ },
+ {
+ "filter": "08_malla",
+ "background": "./backgrounds/08_malla.jpg"
+ }
+ ]
+}
diff --git a/fastlane/screenshots/background-source.jpg b/fastlane/screenshots/background-source.jpg
new file mode 100644
index 0000000..9d0e9df
Binary files /dev/null and b/fastlane/screenshots/background-source.jpg differ
diff --git a/fastlane/screenshots/background.jpg b/fastlane/screenshots/background.jpg
new file mode 100644
index 0000000..fb7f4a6
Binary files /dev/null and b/fastlane/screenshots/background.jpg differ
diff --git a/fastlane/screenshots/backgrounds/01_bienvenida.jpg b/fastlane/screenshots/backgrounds/01_bienvenida.jpg
new file mode 100644
index 0000000..4bf8f99
Binary files /dev/null and b/fastlane/screenshots/backgrounds/01_bienvenida.jpg differ
diff --git a/fastlane/screenshots/backgrounds/02_bienvenida.jpg b/fastlane/screenshots/backgrounds/02_bienvenida.jpg
new file mode 100644
index 0000000..da8f811
Binary files /dev/null and b/fastlane/screenshots/backgrounds/02_bienvenida.jpg differ
diff --git a/fastlane/screenshots/backgrounds/03_oscuro.jpg b/fastlane/screenshots/backgrounds/03_oscuro.jpg
new file mode 100644
index 0000000..84e7ab9
Binary files /dev/null and b/fastlane/screenshots/backgrounds/03_oscuro.jpg differ
diff --git a/fastlane/screenshots/backgrounds/04_calculadora.jpg b/fastlane/screenshots/backgrounds/04_calculadora.jpg
new file mode 100644
index 0000000..52cf028
Binary files /dev/null and b/fastlane/screenshots/backgrounds/04_calculadora.jpg differ
diff --git a/fastlane/screenshots/backgrounds/05_inicio.jpg b/fastlane/screenshots/backgrounds/05_inicio.jpg
new file mode 100644
index 0000000..7b0983c
Binary files /dev/null and b/fastlane/screenshots/backgrounds/05_inicio.jpg differ
diff --git a/fastlane/screenshots/backgrounds/06_credencial.jpg b/fastlane/screenshots/backgrounds/06_credencial.jpg
new file mode 100644
index 0000000..4b8ff08
Binary files /dev/null and b/fastlane/screenshots/backgrounds/06_credencial.jpg differ
diff --git a/fastlane/screenshots/backgrounds/07_horario.jpg b/fastlane/screenshots/backgrounds/07_horario.jpg
new file mode 100644
index 0000000..061ec2a
Binary files /dev/null and b/fastlane/screenshots/backgrounds/07_horario.jpg differ
diff --git a/fastlane/screenshots/backgrounds/08_malla.jpg b/fastlane/screenshots/backgrounds/08_malla.jpg
new file mode 100644
index 0000000..b96ddf6
Binary files /dev/null and b/fastlane/screenshots/backgrounds/08_malla.jpg differ
diff --git a/fastlane/screenshots/es-MX/keyword.strings b/fastlane/screenshots/es-MX/keyword.strings
new file mode 100644
index 0000000..96a305d
--- /dev/null
+++ b/fastlane/screenshots/es-MX/keyword.strings
@@ -0,0 +1,12 @@
+/* Titular de cada captura: la línea grande de arriba. El subtítulo va en title.strings.
+ Los saltos de línea se escriben \n y hay que ponerlos a mano: frameit no corta el texto,
+ sólo lo achica hasta que quepa a lo ancho. Las claves 01 y 02 las usa
+ scripts/compose-store-screenshots, que arma esas dos capturas fuera de frameit. */
+"01_bienvenida" = "";
+"02_bienvenida" = "La aplicación móvil\noficial de la UTEM";
+"03_oscuro" = "Modo Oscuro";
+"04_calculadora" = "Nueva Calculadora";
+"05_inicio" = "Pantalla Principal";
+"06_credencial" = "Ten siempre a mano\ntu Credencial";
+"07_horario" = "Un horario más dinámico";
+"08_malla" = "Revisa tu malla";
diff --git a/fastlane/screenshots/es-MX/title.strings b/fastlane/screenshots/es-MX/title.strings
new file mode 100644
index 0000000..cc185d8
--- /dev/null
+++ b/fastlane/screenshots/es-MX/title.strings
@@ -0,0 +1,9 @@
+/* Subtítulo de cada captura: la línea chica bajo el titular de keyword.strings. */
+"01_bienvenida" = "";
+"02_bienvenida" = "Podrás revisar toda tu información\nacadémica en un solo lugar";
+"03_oscuro" = "Ahora tienes disponible el tan\nsolicitado modo oscuro";
+"04_calculadora" = "Igual de útil, y un nuevo diseño";
+"05_inicio" = "Ahora encuentras tus clases\nde hoy al instante";
+"06_credencial" = "Compatible con SIBUTEM";
+"07_horario" = "Consulta cuál es tu siguiente clase\nen un colorido horario";
+"08_malla" = "Consulta tu avance de malla\nen un formato más cómodo";
diff --git a/fastlane/screenshots/fonts/OFL.txt b/fastlane/screenshots/fonts/OFL.txt
new file mode 100644
index 0000000..9c48e05
--- /dev/null
+++ b/fastlane/screenshots/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/fastlane/screenshots/fonts/Roboto.ttf b/fastlane/screenshots/fonts/Roboto.ttf
new file mode 100644
index 0000000..5522a36
Binary files /dev/null and b/fastlane/screenshots/fonts/Roboto.ttf differ
diff --git a/integration_test/screenshots_test.dart b/integration_test/screenshots_test.dart
new file mode 100644
index 0000000..357c3b3
--- /dev/null
+++ b/integration_test/screenshots_test.dart
@@ -0,0 +1,189 @@
+import "dart:io";
+
+import "package:adaptive_theme/adaptive_theme.dart";
+import "package:flutter/material.dart";
+import "package:flutter_test/flutter_test.dart";
+import "package:integration_test/integration_test.dart";
+import "package:miutem/firebase_options_dev.dart" as dev;
+import "package:miutem/firebase_options_prod.dart" as prod;
+import "package:miutem/main.dart";
+import "package:miutem/screens/asignaturas/lista_asignaturas_screen.dart";
+
+/// Credenciales que se escriben en el formulario. En modo capturas
+/// (`--dart-define=SCREENSHOT_MODE=true`) la app acepta cualquier par y devuelve
+/// siempre el estudiante ficticio de `lib/core/mock/mock_data.dart`.
+const String usuario = "ecarrenos";
+const String clave = "••••••••";
+
+/// Debe coincidir con el flavor con el que se compila (`--flavor production|development`),
+/// porque las opciones de Firebase están atadas al bundle id de cada flavor.
+const bool esProduccion = bool.fromEnvironment("SCREENSHOT_PROD", defaultValue: true);
+
+void main() {
+ final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+ // Las capturas 01 y 02 no salen de la app: son la composición del teléfono en diagonal
+ // que arma `scripts/compose-store-screenshots`. Por eso el recorrido parte en la 03,
+ // y los nombres llevan el número escrito: el orden del archivo es el orden en la tienda.
+ testWidgets("Captura las pantallas de la app para las tiendas", (tester) async {
+ paso(binding, "inicio");
+ runMainApp(esProduccion ? prod.DefaultFirebaseOptions.currentPlatform : dev.DefaultFirebaseOptions.currentPlatform);
+
+ // En modo capturas la sesión vive en memoria, así que cada corrida parte del login.
+ // Si no aparece, la app se compiló sin la bandera y estaría usando datos reales.
+ await esperarPor(tester, binding, botonIngresar,
+ descripcion: "el login (¿falta --dart-define=SCREENSHOT_MODE=true?)",
+ );
+
+ // En Android las capturas se toman del render de Flutter, no de la ventana del
+ // sistema, y hay que convertir la superficie una sola vez antes de la primera.
+ if (Platform.isAndroid) {
+ await binding.convertFlutterSurfaceToImage();
+ paso(binding, "superficie convertida a imagen");
+ }
+
+ final campos = find.byType(TextField);
+ await tester.enterText(campos.at(0), usuario);
+ await tester.enterText(campos.at(1), clave);
+ await tester.tap(botonIngresar);
+ paso(binding, "login enviado");
+
+ final destinos = find.byType(NavigationDestination);
+ await esperarPor(tester, binding, destinos, descripcion: "la navegación principal");
+ final etiquetas = tester.widgetList(destinos).map((destino) => destino.label).toList();
+
+ // 03 — el inicio, pero en oscuro: es la novedad que se está mostrando.
+ await irAPestana(tester, indiceDe(etiquetas, "Inicio"));
+ await cambiarTema(tester, binding, AdaptiveThemeMode.dark);
+ await capturar(tester, binding, "03_oscuro");
+ await cambiarTema(tester, binding, AdaptiveThemeMode.light);
+
+ // 04 — la calculadora de notas, que cuelga de los accesos rápidos de Asignaturas.
+ final idxAsignaturas = indiceDe(etiquetas, "Asignaturas");
+ await irAPestana(tester, idxAsignaturas);
+ await abrirAccesoRapido(tester, binding, "Notas");
+ await capturar(tester, binding, "04_calculadora");
+ await volver(tester);
+
+ // 05 — el inicio en claro, con la primera clase del día en curso.
+ await irAPestana(tester, indiceDe(etiquetas, "Inicio"));
+ await capturar(tester, binding, "05_inicio");
+
+ // 06 — la credencial con el código QR.
+ await irAPestana(tester, indiceDe(etiquetas, "Credencial"));
+ await capturar(tester, binding, "06_credencial");
+
+ // 07 y 08 — horario y malla histórica, también desde los accesos rápidos.
+ await irAPestana(tester, idxAsignaturas);
+ for (final (acceso, archivo) in [("Horario", "07_horario"), ("Malla Histórica", "08_malla")]) {
+ await abrirAccesoRapido(tester, binding, acceso);
+ await capturar(tester, binding, archivo);
+ await volver(tester);
+ }
+ }, timeout: const Timeout(Duration(minutes: 15)));
+}
+
+/// Deja rastro del avance en `reportData`, que el driver escribe en
+/// build/integration_response_data.json incluso cuando el test se cuelga.
+void paso(IntegrationTestWidgetsFlutterBinding binding, String texto) {
+ binding.reportData ??= {};
+ final pasos = binding.reportData!["pasos"] ??= [];
+ (pasos as List).add(texto);
+}
+
+Finder get botonIngresar => find.widgetWithText(FilledButton, "Ingresar");
+
+/// Posición de una pestaña en la barra inferior. Las pestañas dependen de los feature
+/// flags y del perfil, así que se falla con nombre propio en vez de capturar otra cosa.
+int indiceDe(List etiquetas, String etiqueta) {
+ final indice = etiquetas.indexOf(etiqueta);
+ if (indice < 0) {
+ fail("No está la pestaña \"$etiqueta\". Pestañas disponibles: ${etiquetas.join(", ")}.");
+ }
+
+ return indice;
+}
+
+Future irAPestana(WidgetTester tester, int indice) async {
+ await tester.tap(find.byType(NavigationDestination).at(indice));
+ await esperar(tester, const Duration(seconds: 8));
+}
+
+/// Cambia el tema sin pasar por Perfil → Pantalla → Tema: el diálogo obliga a hacer
+/// scroll y a esperar animaciones, y acá sólo interesa con qué brillo se dibuja la app.
+Future cambiarTema(WidgetTester tester, IntegrationTestWidgetsFlutterBinding binding, AdaptiveThemeMode modo) async {
+ AdaptiveTheme.of(tester.element(find.byType(NavigationBar))).setThemeMode(modo);
+ paso(binding, "tema $modo");
+ await esperar(tester, const Duration(seconds: 3));
+}
+
+/// Abre una pantalla desde las tarjetas de acceso rápido y espera a que cargue.
+///
+/// La búsqueda se acota a la pantalla de asignaturas porque el `IndexedStack` de
+/// la navegación mantiene montado el inicio, que tiene accesos rápidos homónimos.
+Future abrirAccesoRapido(WidgetTester tester, IntegrationTestWidgetsFlutterBinding binding, String label) async {
+ final tarjeta = find.descendant(of: find.byType(AsignaturasScreen), matching: find.text(label));
+ if (tarjeta.evaluate().isEmpty) {
+ fail("No se encontró el acceso rápido \"$label\" en la pantalla de asignaturas.");
+ }
+
+ paso(binding, "abriendo $label");
+ await tester.tap(tarjeta.first);
+ await esperar(tester, const Duration(seconds: 8));
+}
+
+Future volver(WidgetTester tester) async {
+ await tester.pageBack();
+ await esperar(tester, const Duration(seconds: 3));
+}
+
+/// Bombea frames hasta que [finder] encuentre algo, o falla el test al agotar
+/// [limite]. El emulador de Android arranca bastante más lento que el simulador
+/// de iOS, así que se espera por lo que hay en pantalla y no por un tiempo fijo.
+Future esperarPor(
+ WidgetTester tester,
+ IntegrationTestWidgetsFlutterBinding binding,
+ Finder finder, {
+ required String descripcion,
+ Duration limite = const Duration(seconds: 90),
+}) async {
+ final fin = DateTime.now().add(limite);
+ while (DateTime.now().isBefore(fin)) {
+ if (finder.evaluate().isNotEmpty) {
+ paso(binding, "apareció $descripcion");
+ return;
+ }
+ await tester.pump(const Duration(milliseconds: 100));
+ }
+
+ fail("No apareció $descripcion después de ${limite.inSeconds}s.");
+}
+
+/// Bombea frames durante [duracion] en tiempo real.
+///
+/// No se usa `pumpAndSettle` porque la app tiene animaciones permanentes
+/// (video del login, skeletons) que nunca dejan el árbol quieto.
+Future esperar(WidgetTester tester, Duration duracion) async {
+ final fin = DateTime.now().add(duracion);
+ while (DateTime.now().isBefore(fin)) {
+ await tester.pump(const Duration(milliseconds: 100));
+ }
+}
+
+/// Quita el foco y espera a que el teclado se retraiga antes de capturar,
+/// porque el teclado del sistema no se captura y dejaría una franja en blanco.
+Future capturar(WidgetTester tester, IntegrationTestWidgetsFlutterBinding binding, String archivo) async {
+ FocusManager.instance.primaryFocus?.unfocus();
+ await esperar(tester, const Duration(seconds: 2));
+
+ // La captura nativa de iOS usa `drawViewHierarchyInRect:afterScreenUpdates:YES`, que espera
+ // un frame nuevo. El binding de tests sólo dibuja cuando se le pide, así que hay que seguir
+ // bombeando mientras se espera la respuesta del canal o se produce un deadlock.
+ var listo = false;
+ final captura = binding.takeScreenshot(archivo).whenComplete(() => listo = true);
+ while (!listo) {
+ await tester.pump(const Duration(milliseconds: 50));
+ }
+ await captura;
+ paso(binding, "capturada $archivo");
+}
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 389cd06..1fea4c8 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -269,7 +269,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
D25030D619DAAB049C253064 /* [CP] Embed Pods Frameworks */,
- 999EDA1A7E03D9C025228306 /* FlutterFire: "flutterfire bundle-service-file" */,
+ 9AEABAB11C91A5FBF0A2679E /* FlutterFire: "flutterfire bundle-service-file" */,
3347830834D1A43F60429BF3 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */,
);
buildRules = (
@@ -446,7 +446,7 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
};
- 999EDA1A7E03D9C025228306 /* FlutterFire: "flutterfire bundle-service-file" */ = {
+ 9AEABAB11C91A5FBF0A2679E /* FlutterFire: "flutterfire bundle-service-file" */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/production.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/production.xcscheme
index 3d12e1d..80e772d 100644
--- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/production.xcscheme
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/production.xcscheme
@@ -45,6 +45,7 @@
buildConfiguration = "Debug-production"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+ customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
@@ -52,6 +53,7 @@
buildConfiguration = "Debug-production"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+ customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile
index 953efaa..63b36cf 100644
--- a/ios/fastlane/Fastfile
+++ b/ios/fastlane/Fastfile
@@ -1,5 +1,18 @@
import "../fastlane/src/helpers.rb"
+require "json"
+
+# Busca el UDID de un simulador por nombre, prefiriendo el runtime de iOS más nuevo.
+def ios_simulator_udid(name)
+ devices = JSON.parse(sh("xcrun", "simctl", "list", "devices", "available", "--json", log: false))["devices"]
+ runtimes = devices.keys.sort_by { |runtime| runtime.scan(/\d+/).map(&:to_i) }.reverse
+ device = runtimes.flat_map { |runtime| devices[runtime] }.find { |d| d["name"] == name }
+
+ UI.user_error!("❌ No existe el simulador '#{name}'. Créalo en Xcode o ajusta SCREENSHOT_DEVICES.") unless device
+
+ device["udid"]
+end
+
platform :ios do
desc "Asegura que exista un keychain limpio para importar los certificados de Apple"
lane :ensure_keychain do |options|
@@ -170,6 +183,96 @@ platform :ios do
end
end
+ # El recorrido no usa `snapshot` porque esa herramienta maneja la app con UI Tests de
+ # XCUITest (Swift), y acá las pantallas son de Flutter: el recorrido vive en
+ # integration_test/screenshots_test.dart y lo ejecuta `flutter drive`, que además sirve
+ # igual para Android. Del flujo de snapshot sí se conserva todo lo demás —la carpeta
+ # fastlane/screenshots// y los nombres "-.png"— para que
+ # frameit y deliver reconozcan el dispositivo sin configuración extra.
+ desc "Genera las capturas de pantalla de iPhone en el simulador y las guarda en fastlane/screenshots"
+ lane :screenshots do |options|
+ devices = array_option(options, :devices, env_var: "SCREENSHOT_DEVICES", default: ["iPhone 17 Pro Max"])
+ locale = string_option(options, :locale, env_var: "SCREENSHOT_LOCALE", default: "es-MX")
+ app_env = string_option(options, :app_env, env_var: "APP_ENV", default: "prod")
+ clear = bool_option(options, :clear, env_var: "SCREENSHOT_CLEAR", default: true)
+ frame = bool_option(options, :frame, env_var: "SCREENSHOT_FRAME", default: true)
+
+ scheme = app_env == "prod" ? "production" : "development"
+ # APP_ENV suele quedar en dev en el .env local, y las capturas de la tienda tienen que
+ # salir del flavor de producción (nombre, ícono y bundle id distintos).
+ UI.important("⚠️ Capturando el flavor #{scheme}. Para la App Store usa `app_env:prod`.") unless app_env == "prod"
+
+ # Ruta absoluta: el CWD de un lane es la carpeta fastlane/, no la raíz del repo.
+ root = sh("git", "rev-parse", "--show-toplevel", log: false).strip
+ screenshots_dir = File.join(root, "fastlane", "screenshots")
+ output_dir = File.join(screenshots_dir, locale)
+
+ # Se borran sólo las imágenes: en la carpeta del idioma también vive el title.strings de frameit.
+ Dir.glob(File.join(output_dir, "*.png")).each { |captura| File.delete(captura) } if clear
+ FileUtils.mkdir_p(output_dir)
+
+ devices.each do |device|
+ udid = ios_simulator_udid(device)
+ UI.message("📱 Preparando #{device} (#{udid})")
+ sh("xcrun", "simctl", "bootstatus", udid, "-b")
+
+ ENV["SCREENSHOTS_DIR"] = output_dir
+ # Mismo formato de nombre que usa snapshot: frameit y deliver deducen de ahí el dispositivo.
+ ENV["SCREENSHOT_PREFIX"] = "#{device}-"
+
+ # SCREENSHOT_MODE hace que la app use los datos ficticios de lib/core/mock/
+ # en vez de conectarse a SIGA y Mi.UTEM: no se necesita ninguna cuenta real.
+ sh(
+ "flutter", "drive",
+ "--driver=test_driver/integration_test.dart",
+ "--target=integration_test/screenshots_test.dart",
+ "--flavor", scheme,
+ "-d", udid,
+ "--dart-define=SCREENSHOT_MODE=true",
+ "--dart-define=SCREENSHOT_PROD=#{app_env == 'prod'}"
+ )
+ end
+
+ # Un timeout del test igual reporta "All tests passed" sin escribir nada, así que se verifica el resultado.
+ capturas = Dir.glob(File.join(output_dir, "*.png"))
+ UI.user_error!("❌ No se generó ninguna captura. Revisa el log de `flutter drive`.") if capturas.empty?
+
+ UI.success("✅ #{capturas.length} capturas guardadas en #{output_dir}")
+
+ if frame
+ # Los fondos se cortan al tamaño exacto de las capturas: si cambia el simulador
+ # cambia el tamaño, y frameit recortaría las tiras y rompería la continuidad.
+ sh(File.join(root, "scripts", "generate-screenshot-backgrounds"), "ios")
+ frame_screenshots(path: screenshots_dir)
+ # Las dos primeras capturas no salen de la app: se componen aparte, y después de
+ # frameit porque escriben directamente sus *_framed.png.
+ sh(File.join(root, "scripts", "compose-store-screenshots"), "ios")
+ end
+ end
+
+ desc "Sube a App Store Connect las capturas ya generadas (sin tocar el binario)"
+ lane :upload_screenshots do |options|
+ app_identifier = string_option(options, :app_identifier, env_var: "APP_IDENTIFIER", required: true)
+
+ screenshots_path = File.join(sh("git", "rev-parse", "--show-toplevel", log: false).strip, "fastlane", "screenshots")
+
+ load_api_key()
+
+ # deliver detecta los *_framed.png y omite las capturas sin marco de la misma carpeta.
+ upload_to_app_store(
+ app_identifier: app_identifier,
+ screenshots_path: screenshots_path,
+ skip_binary_upload: true,
+ skip_metadata: true,
+ skip_screenshots: false,
+ overwrite_screenshots: true,
+ run_precheck_before_submit: false,
+ force: true,
+ )
+
+ UI.success("✅ Capturas subidas a App Store Connect")
+ end
+
desc "Carga la API Key de App Store Connect para autenticación"
lane :load_api_key do |options|
key_id = string_option(options, :key_id, env_var: "APP_STORE_CONNECT_API_KEY_ID", required: true)
diff --git a/lib/core/mock/mock_data.dart b/lib/core/mock/mock_data.dart
new file mode 100644
index 0000000..fbb09e7
--- /dev/null
+++ b/lib/core/mock/mock_data.dart
@@ -0,0 +1,208 @@
+import "package:miutem/core/models/asignaturas/asignatura.dart";
+import "package:miutem/core/models/asignaturas/asignatura_malla.dart";
+import "package:miutem/core/models/carrera.dart";
+import "package:miutem/core/models/evaluacion/evaluacion.dart";
+import "package:miutem/core/models/evaluacion/grades.dart";
+import "package:miutem/core/models/horario.dart";
+import "package:miutem/core/models/user/estudiante.dart";
+import "package:miutem/core/models/user/perfil.dart";
+import "package:miutem/core/models/user/persona/persona.dart";
+import "package:miutem/core/models/user/persona/rut.dart";
+
+/// Datos ficticios usados para generar las capturas de las tiendas.
+/// Ninguno corresponde a una persona, cuenta o matrícula real.
+
+/// Foto del avatar. Es un asset, no una URL: [UserAvatar] distingue por el prefijo.
+const String fotoEstudianteMock = "assets/mock/alex-suprun-unsplash.jpg";
+
+final Estudiante estudianteMock = Estudiante(
+ // El token nunca se valida en modo capturas, pero se marca para que nada intente refrescarlo.
+ token: "mock.screenshot.token",
+ ignoreTokenExpiration: true,
+ rut: Rut(12345678),
+ nombreCompleto: "Ernesto Carreño Silva",
+ correoUtem: "ecarrenos@utem.cl",
+ correoPersonal: "ernesto.carreno.silva@gmail.com",
+ fotoUrl: fotoEstudianteMock,
+ perfiles: const [Perfil.estudiante],
+);
+
+const Carrera carreraMock = Carrera(
+ id: "21041",
+ nombre: "Ingeniería en Informática",
+ estado: "Regular",
+ codigo: "21041",
+);
+
+final Asignatura computacionEnLaNube = Asignatura(
+ id: "1001",
+ codigo: "INFO8002",
+ nombre: "Computación en la Nube",
+ tipoHora: "Cátedra",
+ estado: "Inscrito",
+ seccion: "1",
+ docente: Persona(nombreCompleto: "Camila Rojas Vergara"),
+ tipoAsignatura: "Obligatoria",
+ sala: "M8 - 201",
+ intentos: 1,
+);
+
+final Asignatura trabajoDeTitulo = Asignatura(
+ id: "1002",
+ codigo: "INFB698",
+ nombre: "Trabajo de Título I",
+ tipoHora: "Cátedra",
+ estado: "Inscrito",
+ seccion: "1",
+ docente: Persona(nombreCompleto: "Andrés Fuentes Lillo"),
+ tipoAsignatura: "Obligatoria",
+ sala: "M4 - 302",
+ intentos: 1,
+);
+
+final Asignatura practicaProfesional = Asignatura(
+ id: "1003",
+ codigo: "INFB900",
+ nombre: "Práctica Profesional",
+ tipoHora: "Taller",
+ estado: "Inscrito",
+ seccion: "1",
+ docente: Persona(nombreCompleto: "Paula Navarro Díaz"),
+ tipoAsignatura: "Obligatoria",
+ sala: "M8 - 103",
+ intentos: 1,
+);
+
+final List asignaturasMock = [
+ computacionEnLaNube,
+ trabajoDeTitulo,
+ practicaProfesional,
+];
+
+/// Clases de la semana como (período 1..9, día 1..6, asignatura).
+/// El lunes concentra las tres asignaturas para que "Clases de Hoy" no quede vacío.
+final List<(int, int, Asignatura)> _clasesMock = [
+ (1, 1, computacionEnLaNube),
+ (3, 1, trabajoDeTitulo),
+ (5, 1, practicaProfesional),
+ (2, 2, computacionEnLaNube),
+ (6, 2, trabajoDeTitulo),
+ (1, 3, practicaProfesional),
+ (4, 3, computacionEnLaNube),
+ (3, 4, trabajoDeTitulo),
+ (5, 4, practicaProfesional),
+ (2, 5, computacionEnLaNube),
+ (4, 5, practicaProfesional),
+];
+
+/// Horario de 18 medios bloques x 6 días, igual a la matriz que arma [HorarioService].
+/// Cada clase ocupa los dos medios bloques de su período, como en SIGA.
+Horario horarioMock() {
+ final horario = List.generate(18, (_) => List.generate(6, (_) => BloqueHorario()));
+
+ for (final (periodo, dia, asignatura) in _clasesMock) {
+ for (final fila in [(periodo - 1) * 2, (periodo - 1) * 2 + 1]) {
+ horario[fila][dia - 1] = BloqueHorario(
+ asignatura: asignatura,
+ sala: asignatura.sala,
+ codigo: "${asignatura.codigo}/${asignatura.seccion}",
+ );
+ }
+ }
+
+ return Horario(horario: horario, asignaturas: asignaturasMock);
+}
+
+Grades notasMock(Asignatura asignatura) => switch (asignatura.codigo) {
+ "INFO8002" => Grades(
+ notasParciales: [
+ REvaluacion(descripcion: "Prueba 1", porcentaje: 30, nota: 6.2),
+ REvaluacion(descripcion: "Prueba 2", porcentaje: 30, nota: 5.8),
+ REvaluacion(descripcion: "Laboratorios", porcentaje: 40, nota: 6.5),
+ ],
+ notaPresentacion: 6.2,
+ notaFinal: 6.2,
+ ),
+ "INFB698" => Grades(
+ notasParciales: [
+ REvaluacion(descripcion: "Avance 1", porcentaje: 40, nota: 6.8),
+ REvaluacion(descripcion: "Avance 2", porcentaje: 60, nota: 6.4),
+ ],
+ notaPresentacion: 6.6,
+ notaFinal: 6.6,
+ ),
+ _ => Grades(
+ notasParciales: [
+ REvaluacion(descripcion: "Informe de Práctica", porcentaje: 60, nota: 6.9),
+ REvaluacion(descripcion: "Evaluación de la Empresa", porcentaje: 40, nota: 7.0),
+ ],
+ notaPresentacion: 6.9,
+ notaFinal: 6.9,
+ ),
+};
+
+AsignaturaMalla _aprobada(int nivel, String nombre, String nota, {String tipo = "Obligatoria"}) =>
+ AsignaturaMalla(nivel: nivel, nombre: nombre, tipo: tipo, intentos: 1, estado: "Aprobado", nota: nota);
+
+AsignaturaMalla _inscrita(int nivel, String nombre, {String tipo = "Obligatoria"}) =>
+ AsignaturaMalla(nivel: nivel, nombre: nombre, tipo: tipo, intentos: 1, estado: "Inscrito", nota: "-");
+
+AsignaturaMalla _noCursada(int nivel, String nombre, {String tipo = "Obligatoria"}) =>
+ AsignaturaMalla(nivel: nivel, nombre: nombre, tipo: tipo, intentos: 0, estado: "No Cursado", nota: "-");
+
+/// Malla de Ingeniería en Informática, con las tres asignaturas en curso inscritas
+/// y Trabajo de Título II todavía sin cursar.
+final List mallaMock = [
+ // Nivel 1
+ _aprobada(1, "Habilidades de Razonamiento Lógico", "6,2", tipo: "Nivelación"),
+ _aprobada(1, "Taller de Ciencia y Tecnología", "4,5"),
+ _aprobada(1, "Introducción a la Ingeniería en Informática", "6,0"),
+ _aprobada(1, "Algoritmos y Programación", "6,4"),
+ _aprobada(1, "Taller de Matemática", "4,0"),
+ _aprobada(1, "Design Thinking", "5,0"),
+ // Nivel 2
+ _aprobada(2, "Electivo de Formación General I", "6,0", tipo: "Electivo"),
+ _aprobada(2, "Habilidades de Trabajo Académico", "5,4", tipo: "Nivelación"),
+ _aprobada(2, "Cálculo Diferencial", "4,0"),
+ _aprobada(2, "Estructuras de Datos", "5,1"),
+ _aprobada(2, "Mecánica Clásica", "4,2"),
+ _aprobada(2, "Álgebra Clásica", "4,4"),
+ // Nivel 3
+ _aprobada(3, "Electromagnetismo", "4,1"),
+ _aprobada(3, "Bases de Datos", "5,4"),
+ _aprobada(3, "Lenguajes de Programación", "4,2"),
+ _aprobada(3, "Cálculo Integral", "4,4"),
+ _aprobada(3, "Álgebra Superior", "4,3"),
+ // Nivel 4
+ _aprobada(4, "Arquitectura de Computadores", "5,1"),
+ _aprobada(4, "Ingeniería Ambiental", "6,9"),
+ _aprobada(4, "Grafos y Lenguajes Formales", "4,0"),
+ _aprobada(4, "Sistemas de Información", "6,3"),
+ _aprobada(4, "Estadística y Probabilidad", "4,0"),
+ _aprobada(4, "Taller de Principios de Sustentabilidad", "6,6"),
+ // Nivel 5
+ _aprobada(5, "Sistemas Operativos", "4,2"),
+ _aprobada(5, "Circuitos Eléctricos", "4,4"),
+ _aprobada(5, "Principios de Economía", "4,8"),
+ _aprobada(5, "Desarrollo Ágil", "5,6"),
+ _aprobada(5, "Análisis de Algoritmos", "4,2"),
+ _aprobada(5, "Inglés I", "4,2"),
+ // Nivel 6
+ _aprobada(6, "Redes y Comunicación de Datos", "7,0"),
+ _aprobada(6, "Ciberseguridad", "6,2"),
+ _aprobada(6, "Inglés II", "5,1"),
+ _aprobada(6, "Fundamentos de Data Science", "6,1"),
+ _aprobada(6, "Evaluación de Proyectos Informáticos", "4,0"),
+ _aprobada(6, "Ingeniería de Software", "5,1"),
+ // Nivel 7
+ _aprobada(7, "Electivo de Formación Especializada I", "6,8", tipo: "Electivo"),
+ _aprobada(7, "Electivo de Formación Especializada II", "6,3", tipo: "Electivo"),
+ _inscrita(7, "Trabajo de Título I"),
+ _aprobada(7, "Gestión de Proyectos Informáticos", "5,1"),
+ _inscrita(7, "Computación en la Nube"),
+ _aprobada(7, "Computación Web y Móvil", "4,5"),
+ // Nivel 8
+ _noCursada(8, "Trabajo de Título II"),
+ _inscrita(8, "Práctica Profesional"),
+ _aprobada(8, "Taller de Innovación y Emprendimiento", "6,4"),
+];
diff --git a/lib/core/mock/mock_services.dart b/lib/core/mock/mock_services.dart
new file mode 100644
index 0000000..c89c398
--- /dev/null
+++ b/lib/core/mock/mock_services.dart
@@ -0,0 +1,143 @@
+import "package:flutter/material.dart";
+import "package:get/get.dart";
+import "package:miutem/core/mock/mock_data.dart";
+import "package:miutem/core/models/asignaturas/asignatura.dart";
+import "package:miutem/core/models/asignaturas/asignatura_malla.dart";
+import "package:miutem/core/models/carrera.dart";
+import "package:miutem/core/models/evaluacion/grades.dart";
+import "package:miutem/core/models/exceptions/custom_exception.dart";
+import "package:miutem/core/models/horario.dart";
+import "package:miutem/core/models/user/credential.dart";
+import "package:miutem/core/models/user/estudiante.dart";
+import "package:miutem/core/models/user/persona/persona.dart";
+import "package:miutem/core/repositories/secure_storage_repository.dart";
+import "package:miutem/core/services/asignaturas_service.dart";
+import "package:miutem/core/services/auth_service.dart";
+import "package:miutem/core/services/carrera_service.dart";
+import "package:miutem/core/services/grades_service.dart";
+import "package:miutem/core/services/horario_service.dart";
+import "package:miutem/core/services/mi_utem/miutem_auth_service.dart";
+import "package:miutem/core/services/mi_utem/miutem_malla_service.dart";
+
+/// Reemplaza en GetX todos los servicios que salen a la red por versiones que
+/// devuelven los datos ficticios de `mock_data.dart`.
+///
+/// Se llama sólo cuando `modoCapturas` es verdadero. Las pantallas y sus estados
+/// de carga siguen siendo los reales: lo único que cambia es el origen de los datos.
+void registrarServiciosMock() {
+ Get.lazyReplace(() => MockSecureStorageRepository());
+ Get.lazyReplace(() => MockAuthService());
+ Get.lazyReplace(() => MockCarreraService());
+ Get.lazyReplace(() => MockAsignaturasService());
+ Get.lazyReplace(() => MockGradesService());
+ Get.lazyReplace(() => MockHorarioService());
+ Get.lazyReplace(() => MockMiUTEMAuthService());
+ Get.lazyReplace(() => MockMiUTEMMallaService());
+}
+
+/// Storage en memoria: cada corrida parte sin sesión, así el recorrido de
+/// capturas siempre empieza en el login sin depender del estado del simulador.
+class MockSecureStorageRepository extends SecureStorageRepository {
+ Credentials? _credenciales;
+ Estudiante? _estudiante;
+ String? _cookies;
+
+ @override
+ Future getMiUTEMCookies() async => _cookies;
+
+ @override
+ Future hasMiUTEMCookies() async => _cookies != null;
+
+ @override
+ Future setMiUTEMCookies(String? cookies) async => _cookies = cookies;
+
+ @override
+ Future getEstudiante() async => _estudiante;
+
+ @override
+ Future hasEstudiante() async => _estudiante != null;
+
+ @override
+ Future setEstudiante(Estudiante? estudiante) async => _estudiante = estudiante;
+
+ @override
+ Future getCredentials() async => _credenciales;
+
+ @override
+ Future hasCredentials() async => _credenciales != null;
+
+ @override
+ Future setCredentials(Credentials? credential) async => _credenciales = credential;
+}
+
+/// Acepta cualquier usuario y clave, y siempre devuelve el mismo estudiante.
+class MockAuthService extends AuthService {
+ final SecureStorageRepository _storage = Get.find();
+
+ bool _logueado = false;
+
+ @override
+ Estudiante? get cachedEstudiante => _logueado ? estudianteMock : null;
+
+ @override
+ Future isFirstTime() async => false;
+
+ @override
+ Future isLoggedIn() async => await _storage.getCredentials() != null;
+
+ @override
+ Future login({bool forceRefresh = false}) async {
+ if (await _storage.getCredentials() == null) {
+ throw CustomException.custom(message: "No se encontraron credenciales.");
+ }
+
+ await _storage.setEstudiante(estudianteMock);
+ _logueado = true;
+ return estudianteMock;
+ }
+
+ @override
+ Future activeToken() async => estudianteMock.token;
+
+ @override
+ Future logout({BuildContext? context}) async {
+ _logueado = false;
+ await super.logout(context: context);
+ }
+}
+
+class MockCarreraService extends CarreraService {
+ @override
+ Future getCarrera({bool forceRefresh = false}) async => carreraMock;
+}
+
+class MockAsignaturasService extends AsignaturasService {
+ @override
+ Future> getAsignaturas({bool forceRefresh = false}) async => asignaturasMock;
+
+ @override
+ Future> getEstudiantes(Asignatura asignatura, {bool forceRefresh = false}) async => [];
+}
+
+class MockGradesService extends GradesService {
+ @override
+ Future getGrades(Asignatura asignatura, {forceRefresh = false}) async => notasMock(asignatura);
+}
+
+class MockHorarioService extends HorarioService {
+ @override
+ Future getHorario({bool forceRefresh = false}) async => horarioMock();
+}
+
+class MockMiUTEMAuthService extends MiUTEMAuthService {
+ @override
+ Future login() async => "sessionid=mock";
+
+ @override
+ Future isLoggedIn() async => true;
+}
+
+class MockMiUTEMMallaService extends MiUTEMMallaService {
+ @override
+ Future> getMalla({bool forceRefresh = false}) async => mallaMock;
+}
diff --git a/lib/core/services/controllers/horario_controller.dart b/lib/core/services/controllers/horario_controller.dart
index 4fb352a..46b72a8 100644
--- a/lib/core/services/controllers/horario_controller.dart
+++ b/lib/core/services/controllers/horario_controller.dart
@@ -57,7 +57,7 @@ class HorarioController {
];
final _randomColors = List.from(_pastelColors)..shuffle();
- final _now = DateTime.now();
+ final _now = ahora();
num daysCount = 6;
num periodsCount = 9;
diff --git a/lib/core/services/controllers/local_notifications_controller.dart b/lib/core/services/controllers/local_notifications_controller.dart
index 2b9aba1..d946c81 100644
--- a/lib/core/services/controllers/local_notifications_controller.dart
+++ b/lib/core/services/controllers/local_notifications_controller.dart
@@ -1,6 +1,7 @@
import "package:awesome_notifications/awesome_notifications.dart";
import "package:flutter/material.dart";
+import "package:miutem/core/utils/constants.dart";
class NotificationController {
@@ -27,6 +28,12 @@ class NotificationController {
// TODO debo cambiar la logica para que verifique si existe la preferencia allow notifications y que si no existe pregunte, si existe que no pida permisos.
// TODO En teoria si esto funciona bien, sera llamado cada vez que se quiera crear una task para verificar si existe el permiso o no.
static Future checkAndRequestNotificationPermissions() async {
+ // Al generar las capturas para las tiendas no se pide el permiso: el diálogo del sistema
+ // deja la app inactiva y la pantalla se queda congelada en el splash.
+ if (modoCapturas) {
+ return;
+ }
+
bool isAllowed = await AwesomeNotifications().isNotificationAllowed();
if (!isAllowed) {
await AwesomeNotifications().requestPermissionToSendNotifications();
diff --git a/lib/core/services/service_manager.dart b/lib/core/services/service_manager.dart
index 627f3d4..53c19e6 100644
--- a/lib/core/services/service_manager.dart
+++ b/lib/core/services/service_manager.dart
@@ -4,6 +4,7 @@ import "package:firebase_core/firebase_core.dart" show Firebase, FirebaseOptions
import "package:firebase_crashlytics/firebase_crashlytics.dart";
import "package:flutter/cupertino.dart";
import "package:get/get.dart";
+import "package:miutem/core/mock/mock_services.dart";
import "package:miutem/core/models/config/user_config.dart";
import "package:miutem/core/repositories/secure_storage_repository.dart";
import "package:miutem/core/services/asignaturas_service.dart";
@@ -18,6 +19,7 @@ import "package:miutem/core/services/horario_service.dart";
import "package:miutem/core/services/mi_utem/miutem_auth_service.dart";
import "package:miutem/core/services/mi_utem/miutem_malla_service.dart";
import "package:miutem/core/services/controllers/horario_controller.dart";
+import "package:miutem/core/utils/constants.dart";
/// Inicializa los servicios y los registra en GetX
Future initServices(FirebaseOptions firebaseOptions) async {
@@ -58,4 +60,8 @@ Future initServices(FirebaseOptions firebaseOptions) async {
// Inicializar preferencias de usuario
Get.put(UserConfig());
+ // Al generar las capturas para las tiendas se usan datos ficticios en vez de SIGA y Mi.UTEM.
+ if (modoCapturas) {
+ registrarServiciosMock();
+ }
}
\ No newline at end of file
diff --git a/lib/core/utils/constants.dart b/lib/core/utils/constants.dart
index a8b146d..f087287 100644
--- a/lib/core/utils/constants.dart
+++ b/lib/core/utils/constants.dart
@@ -2,6 +2,16 @@ import "package:flutter/services.dart";
import "package:flutter_secure_storage/flutter_secure_storage.dart";
import "package:shared_preferences/shared_preferences.dart";
+/// Modo de generación de capturas para las tiendas: la app no habla con SIGA ni
+/// con Mi.UTEM, sino que usa los datos ficticios de `lib/core/mock/`.
+/// Se activa con `--dart-define=SCREENSHOT_MODE=true` (lane `ios screenshots`).
+const bool modoCapturas = bool.fromEnvironment("SCREENSHOT_MODE");
+
+/// Fecha que la app da por "hoy" en modo capturas: lunes 30 de agosto,
+/// aniversario de la fundación de la UTEM (30/08/1993). El año es el próximo
+/// en que el 30 de agosto cae lunes, para que el horario muestre día hábil.
+final DateTime fechaCapturas = DateTime(2027, 8, 30, 8, 30);
+
/// UUID Namespace es para generar IDs determinísticos usando UUID v5, por ejemplo para generar un ID de usuario a partir de su correo electrónico sin necesidad de guardar el ID en la base de datos.
const miutemUuidNamespace = "a590b229-221c-4d24-a41d-23f5b60ce757";
const sigaHost = "https://siga.utem.cl";
diff --git a/lib/core/utils/utilities.dart b/lib/core/utils/utilities.dart
index 34d6e7b..973d313 100644
--- a/lib/core/utils/utilities.dart
+++ b/lib/core/utils/utilities.dart
@@ -76,9 +76,13 @@ Color fromHex(String hexString) {
return Color(int.parse(buffer.toString(), radix: 16));
}
+/// Fecha y hora actual. En modo capturas siempre devuelve [fechaCapturas], para
+/// que el saludo, el horario y las clases de hoy salgan iguales en cada corrida.
+DateTime ahora() => modoCapturas ? fechaCapturas : DateTime.now();
+
/// Obtiene el día actual en formato 'Día, Numero de Mes'.
String getToday() {
- final now = DateTime.now();
+ final now = ahora();
return "${days[now.weekday - 1]}, ${now.day} de ${months[now.month - 1]}";
}
diff --git a/lib/screens/home/actions/cargar_clases_de_hoy.dart b/lib/screens/home/actions/cargar_clases_de_hoy.dart
index 4e6484f..8d09b74 100644
--- a/lib/screens/home/actions/cargar_clases_de_hoy.dart
+++ b/lib/screens/home/actions/cargar_clases_de_hoy.dart
@@ -2,19 +2,20 @@ import "package:get/get.dart";
import "package:miutem/core/models/exceptions/custom_exception.dart";
import "package:miutem/core/models/horario.dart";
import "package:miutem/core/services/horario_service.dart";
+import "package:miutem/core/utils/utils.dart";
Future?> cargarClasesDeHoy({ bool forceRefresh = false }) async {
try {
final horario = await Get.find().getHorario(forceRefresh: forceRefresh);
- final diaIdx = DateTime.now().weekday - 1;
+ final diaIdx = ahora().weekday - 1;
if(diaIdx < 0 || diaIdx >= (horario.horario?.first.length ?? 0)) {
- return Future.error("No tienes clases hoy.");
+ throw CustomException(message: "No tienes clases hoy.");
}
final clasesDeHoy = horario.horario?.map((row) => row[diaIdx]).toList();
final bloques = (clasesDeHoy?.asMap().entries.where((entry) => entry.key % 2 == 0).map((entry) => entry.value).toList())?.toList();
if (clasesDeHoy == null || bloques?.where((bloque) => bloque.asignatura != null).isEmpty == true) {
- return Future.error("No tienes clases hoy.");
+ throw CustomException(message: "No tienes clases hoy.");
}
return bloques;
diff --git a/lib/screens/home/widgets/clases_de_hoy/card_clase.dart b/lib/screens/home/widgets/clases_de_hoy/card_clase.dart
index d02e8a8..277a917 100644
--- a/lib/screens/home/widgets/clases_de_hoy/card_clase.dart
+++ b/lib/screens/home/widgets/clases_de_hoy/card_clase.dart
@@ -1,5 +1,6 @@
import "dart:async";
import "package:flutter/material.dart";
+import "package:miutem/core/utils/utils.dart";
import "package:miutem/styles/styles.dart";
class CardClase extends StatefulWidget {
@@ -26,7 +27,7 @@ class _CardClaseState extends State with WidgetsBindingObserver {
void initState() {
Timer.periodic(const Duration(seconds: 30), (timer) {
// Si es la hora de inicio o fin de la clase (en el mismo minuto) forzamos un rebuild
- final now = DateTime.now();
+ final now = ahora();
final isHoraInicio = now.hour == int.parse(widget.horaInicio.split(":")[0]) && now.minute == int.parse(widget.horaInicio.split(":")[1]);
final isHoraFin = now.hour == int.parse(widget.horaFin.split(":")[0]) && now.minute == int.parse(widget.horaFin.split(":")[1]);
@@ -59,7 +60,9 @@ class _CardClaseState extends State with WidgetsBindingObserver {
}
bool isCurrentClassActive() {
- final now = DateTime.now();
+ // `ahora()` y no `DateTime.now()`: en modo capturas la hora está fijada, y con la
+ // hora real ninguna clase salía marcada como la que está en curso.
+ final now = ahora();
final start = DateTime(now.year, now.month, now.day, int.parse(widget.horaInicio.split(":")[0]), int.parse(widget.horaInicio.split(":")[1]));
final end = DateTime(now.year, now.month, now.day, int.parse(widget.horaFin.split(":")[0]), int.parse(widget.horaFin.split(":")[1]));
return now.isAfter(start) && now.isBefore(end);
diff --git a/lib/screens/home/widgets/saludo.dart b/lib/screens/home/widgets/saludo.dart
index 86d33ce..39f32c6 100644
--- a/lib/screens/home/widgets/saludo.dart
+++ b/lib/screens/home/widgets/saludo.dart
@@ -48,7 +48,7 @@ class _SaludoState extends State with SingleTickerProviderStateMixin {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Text(let(DateTime.now().hour, (hour) => hour >= 6 && hour < 12 ? "¡Buenos Días!," : (hour >= 12 && hour < 19 ? "¡Buenas Tardes!," : "¡Buenas Noches!,")) ?? "¡Te damos la Bienvenida!,",
+ Text(let(ahora().hour, (hour) => hour >= 6 && hour < 12 ? "¡Buenos Días!," : (hour >= 12 && hour < 19 ? "¡Buenas Tardes!," : "¡Buenas Noches!,")) ?? "¡Te damos la Bienvenida!,",
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w900),
),
Skeletonizer(
diff --git a/lib/screens/horario/widgets/screens/horario_indicator.dart b/lib/screens/horario/widgets/screens/horario_indicator.dart
index 8342e9f..e562cb1 100644
--- a/lib/screens/horario/widgets/screens/horario_indicator.dart
+++ b/lib/screens/horario/widgets/screens/horario_indicator.dart
@@ -3,6 +3,7 @@ import "dart:async";
import "package:flutter/material.dart";
import "package:get/get.dart";
import "package:miutem/core/services/controllers/horario_controller.dart";
+import "package:miutem/core/utils/utils.dart";
import "../widgets.dart";
@@ -94,7 +95,7 @@ class _HorarioIndicatorState extends State {
BorderRadius.circular(HorarioIndicator._circleRadius),
),
child: _horarioController.indicatorIsOpen.value ? Center(
- child: TickerTimeText(time: DateTime.now()),
+ child: TickerTimeText(time: ahora()),
) : Container(),
)),
),
diff --git a/lib/widgets/user_avatar.dart b/lib/widgets/user_avatar.dart
index acbad66..8ce19d4 100644
--- a/lib/widgets/user_avatar.dart
+++ b/lib/widgets/user_avatar.dart
@@ -19,10 +19,15 @@ class UserAvatar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final effectiveFontSize = fontSize ?? (radius * 0.8);
+ final foto = estudiante?.fotoUrl;
return CircleAvatar(
radius: radius,
backgroundColor: AppTheme.colorScheme.primary.withValues(alpha: 0.2),
+ // Si la foto no carga (URL caída, sin sesión) el CircleAvatar deja ver las iniciales de abajo.
+ foregroundImage: foto == null || foto.isEmpty
+ ? null
+ : (foto.startsWith("assets/") ? AssetImage(foto) as ImageProvider : NetworkImage(foto)),
child: Text(
estudiante?.iniciales[0] ?? "J",
style: TextStyle(
diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj
index eda43b0..4795118 100644
--- a/macos/Runner.xcodeproj/project.pbxproj
+++ b/macos/Runner.xcodeproj/project.pbxproj
@@ -272,7 +272,7 @@
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* Run Script */,
66347C3AB20C597EF927CF2E /* [CP] Embed Pods Frameworks */,
- E7384BC89DD8E9EE0B609C6A /* FlutterFire: "flutterfire bundle-service-file" */,
+ 5B50C85DCDCD05C64FE50539 /* FlutterFire: "flutterfire bundle-service-file" */,
9386F2DC54D032B1619C6E45 /* FlutterFire: "flutterfire upload-crashlytics-symbols" */,
);
buildRules = (
@@ -423,6 +423,24 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
+ 5B50C85DCDCD05C64FE50539 /* FlutterFire: "flutterfire bundle-service-file" */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "FlutterFire: \"flutterfire bundle-service-file\"";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\n#!/bin/bash\nPATH=\"${PATH}:$FLUTTER_ROOT/bin:${PUB_CACHE}/bin:$HOME/.pub-cache/bin\"\n# Define the Resources directory path\nRESOURCES_DIR=\"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Resources\"\n\n# Create the Resources directory if it does not exist\n mkdir -p \"$RESOURCES_DIR\"\n\n flutterfire bundle-service-file --plist-destination=\"$RESOURCES_DIR\" --build-configuration=${CONFIGURATION} --platform=macos --apple-project-path=\"${SRCROOT}\"\n";
+ };
66347C3AB20C597EF927CF2E /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -480,24 +498,6 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- E7384BC89DD8E9EE0B609C6A /* FlutterFire: "flutterfire bundle-service-file" */ = {
- isa = PBXShellScriptBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- inputFileListPaths = (
- );
- inputPaths = (
- );
- name = "FlutterFire: \"flutterfire bundle-service-file\"";
- outputFileListPaths = (
- );
- outputPaths = (
- );
- runOnlyForDeploymentPostprocessing = 0;
- shellPath = /bin/sh;
- shellScript = "\n#!/bin/bash\nPATH=\"${PATH}:$FLUTTER_ROOT/bin:${PUB_CACHE}/bin:$HOME/.pub-cache/bin\"\n# Define the Resources directory path\nRESOURCES_DIR=\"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Contents/Resources\"\n\n# Create the Resources directory if it does not exist\n mkdir -p \"$RESOURCES_DIR\"\n\n flutterfire bundle-service-file --plist-destination=\"$RESOURCES_DIR\" --build-configuration=${CONFIGURATION} --platform=macos --apple-project-path=\"${SRCROOT}\"\n";
- };
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
diff --git a/pubspec.lock b/pubspec.lock
index 1916b48..8242f6a 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -406,6 +406,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.1"
+ flutter_driver:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
@@ -426,18 +431,18 @@ packages:
dependency: "direct main"
description:
name: flutter_secure_storage
- sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
+ sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6"
url: "https://pub.dev"
source: hosted
- version: "10.3.1"
+ version: "11.0.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
- sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
+ sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0
url: "https://pub.dev"
source: hosted
- version: "0.3.2"
+ version: "0.4.0"
flutter_secure_storage_linux:
dependency: transitive
description:
@@ -488,6 +493,11 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
+ fuchsia_remote_debug_protocol:
+ dependency: transitive
+ description: flutter
+ source: sdk
+ version: "0.0.0"
get:
dependency: "direct main"
description:
@@ -516,10 +526,10 @@ packages:
dependency: transitive
description:
name: hooks
- sha256: "03a564c3704524ee0f7fc56fc621e8796cc2eb8c24d1f1a33b34979815b285c4"
+ sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
- version: "2.1.0"
+ version: "2.0.2"
html:
dependency: "direct main"
description:
@@ -568,6 +578,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.9.1"
+ integration_test:
+ dependency: "direct dev"
+ description: flutter
+ source: sdk
+ version: "0.0.0"
intl:
dependency: transitive
description:
@@ -824,6 +839,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.5.2"
+ process:
+ dependency: transitive
+ description:
+ name: process
+ sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
+ url: "https://pub.dev"
+ source: hosted
+ version: "5.0.5"
pub_semver:
dependency: transitive
description:
@@ -860,10 +883,10 @@ packages:
dependency: transitive
description:
name: record_use
- sha256: af37186ff9ede46fa32f152526c48f46c5b3ad7d3d5a566140cb5df3dc4ed737
+ sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
- version: "1.0.0"
+ version: "0.6.0"
rxdart:
dependency: transitive
description:
@@ -1053,6 +1076,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
+ sync_http:
+ dependency: transitive
+ description:
+ name: sync_http
+ sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.3.1"
synchronized:
dependency: transitive
description:
@@ -1253,6 +1284,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
+ webdriver:
+ dependency: transitive
+ description:
+ name: webdriver
+ sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
+ url: "https://pub.dev"
+ source: hosted
+ version: "3.1.0"
win32:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 5d6db79..8ce1b7c 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -2,7 +2,7 @@ name: miutem
description: "Plataforma académica para estudiantes de la Universidad Tecnológica Metropolitana (UTEM)"
publish_to: none
-version: 4.0.0+150
+version: 4.0.0+152
environment:
sdk: ^3.11.1
@@ -40,7 +40,7 @@ dependencies:
cloud_firestore: ^6.8.0
# Apuntes
sqflite: ^2.4.1
- flutter_secure_storage: ^10.3.1
+ flutter_secure_storage: ^11.0.0
path_provider: ^2.1.5
# Awesome-notifications LOCAL
awesome_notifications_core: ^0.10.1
@@ -63,6 +63,8 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
+ integration_test:
+ sdk: flutter
flutter_lints: ^6.0.0
flutter:
@@ -88,3 +90,5 @@ flutter:
- assets/images/exdev.png
- assets/images/miutem_claro.png
- assets/images/miutem_oscuro.png
+ # Datos ficticios para las capturas de las tiendas (SCREENSHOT_MODE)
+ - assets/mock/alex-suprun-unsplash.jpg
diff --git a/scripts/compose-store-screenshots b/scripts/compose-store-screenshots
new file mode 100755
index 0000000..c521a9c
--- /dev/null
+++ b/scripts/compose-store-screenshots
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+# Arma las dos primeras capturas de la ficha: un teléfono en diagonal con la pantalla de
+# splash, que ocupa la captura 1 (junto al logo de la UTEM) y se corta en la 2, donde va
+# el texto. Son las únicas dos que no salen de la app, así que no pasan por frameit; el
+# marco del teléfono sí se lo pide prestado, porque frameit sin Framefile en la carpeta
+# deja el marco solo, con el resto transparente.
+#
+# Se ejecuta después de `frameit`, porque escribe directamente los *_framed.png.
+#
+# Uso: scripts/compose-store-screenshots [ios|android|all]
+#
+# La plataforma importa porque en CI cada una corre en un job (y un runner) distinto: sin
+# el argumento, el job de iOS moriría buscando las capturas de Android y viceversa.
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+ROTACION=-18 # Grados de inclinación del teléfono.
+ALTO_TELEFONO=0.92 # Alto del teléfono en altos de captura, antes de inclinarlo.
+CENTRO_X=0.95 # Centro del teléfono, en anchos de captura desde el borde izquierdo.
+CENTRO_Y=0.48
+LOGO_ANCHO=0.46 # Ancho del logo de la UTEM, en anchos de captura.
+LOGO_MARGEN=0.06
+TEXTO_CENTRO_X=1.58 # Centro del texto, en anchos de captura: corrido a la derecha para
+TEXTO_ARRIBA=0.10 # no chocar con la punta del teléfono, que entra en la segunda captura.
+
+command -v magick >/dev/null || { echo "❌ Falta ImageMagick (brew install imagemagick)" >&2; exit 1; }
+command -v fastlane >/dev/null || { echo "❌ fastlane no está en el PATH" >&2; exit 1; }
+
+FUENTE_TEXTO="fastlane/screenshots/fonts/Roboto.ttf"
+LOGO_UTEM="assets/images/utem_logo_color_blanco.png" # El blanco: va sobre el fondo oscuro.
+SPLASH_LOGO="assets/launcher_icons/production/miutem-splash-light.png"
+SPLASH_MARCA="assets/images/exdev_dark.png"
+SPLASH_FONDO="#fafafa" # El mismo de flutter_native_splash-production.yaml.
+
+calc() { python3 -c "print(round($1))"; }
+
+# Valor de una clave en un archivo .strings, con los \n convertidos en saltos reales.
+texto_de() {
+ printf "%b" "$(sed -n "s/^\"$2\" = \"\(.*\)\";$/\1/p" "$1")"
+}
+
+# Tamaño de fuente declarado en el Framefile, para que estas dos capturas usen el mismo
+# cuerpo que las que arma frameit.
+tamano_fuente() {
+ python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['default'][sys.argv[2]]['font_size'])" "$1" "$2"
+}
+
+# componer [plataforma de frameit]
+componer() {
+ local base=$1 locale=$2 plataforma=${3:-}
+ local dir="$base/$locale"
+
+ # Las capturas de la app mandan sobre el tamaño y sobre el prefijo de dispositivo: en
+ # iOS los archivos se llaman "-NN_pantalla.png" y frameit lee de ahí el marco.
+ local muestra
+ muestra=$(find "$dir" -name "*.png" ! -name "*_framed.png" | sort | head -1 || true)
+ [ -n "$muestra" ] || { echo "❌ No hay capturas en $dir: genéralas antes." >&2; exit 1; }
+
+ local ancho alto prefijo
+ read -r ancho alto <<<"$(magick identify -format "%w %h" "$muestra")"
+ prefijo="$(basename "$muestra")"
+ prefijo="${prefijo%%[0-9][0-9]_*}"
+
+ local tmp
+ tmp=$(mktemp -d)
+
+ # 1. La pantalla de splash, con los mismos assets que flutter_native_splash.
+ magick -size "${ancho}x${alto}" "xc:$SPLASH_FONDO" \
+ \( "$SPLASH_LOGO" -resize "$((ancho * 46 / 100))x" \) -gravity center -composite \
+ \( "$SPLASH_MARCA" -resize "$((ancho * 26 / 100))x" \) -gravity south -geometry "+0+$((alto * 5 / 100))" -composite \
+ "$tmp/${prefijo}splash.png"
+
+ # 2. El marco del teléfono, prestado de frameit.
+ (cd "$tmp" && fastlane frameit ${plataforma:+"$plataforma"} >/dev/null 2>&1)
+ local telefono="$tmp/${prefijo}splash_framed.png"
+ [ -f "$telefono" ] || { echo "❌ frameit no dejó el marco en $telefono" >&2; rm -rf "$tmp"; exit 1; }
+
+ # 3. El lienzo de las dos capturas juntas, con el trozo de foto de fondo que les toca.
+ magick "$base/backgrounds/01_bienvenida.jpg" "$base/backgrounds/02_bienvenida.jpg" +append "$tmp/lienzo.png"
+
+ # 4. El teléfono inclinado, cruzando la unión entre las dos capturas.
+ magick "$telefono" -background none -resize "x$(calc "$alto * $ALTO_TELEFONO")" -rotate "$ROTACION" "$tmp/telefono.png"
+
+ local tel_ancho tel_alto
+ read -r tel_ancho tel_alto <<<"$(magick identify -format "%w %h" "$tmp/telefono.png")"
+
+ magick "$tmp/lienzo.png" \
+ "$tmp/telefono.png" -gravity northwest \
+ -geometry "$(printf "%+d%+d" "$(calc "$ancho * $CENTRO_X - $tel_ancho / 2")" "$(calc "$alto * $CENTRO_Y - $tel_alto / 2")")" -composite \
+ \( "$LOGO_UTEM" -resize "$(calc "$ancho * $LOGO_ANCHO")x" \) -gravity southwest \
+ -geometry "+$(calc "$ancho * $LOGO_MARGEN")+$(calc "$alto * $LOGO_MARGEN")" -composite \
+ "$tmp/compuesto.png"
+
+ # 5. El texto, centrado en la mitad derecha, con el mismo par titular/subtítulo del resto.
+ local titular subtitulo cuerpo_titular cuerpo_subtitulo
+ titular=$(texto_de "$dir/keyword.strings" "02_bienvenida")
+ subtitulo=$(texto_de "$dir/title.strings" "02_bienvenida")
+ cuerpo_titular=$(tamano_fuente "$base/Framefile.json" keyword)
+ cuerpo_subtitulo=$(tamano_fuente "$base/Framefile.json" title)
+
+ # El espacio entre titular y subtítulo se hace con -splice y no con una imagen vacía:
+ # el -size de esa imagen se quedaría pegado y recortaría el label siguiente.
+ magick -background none -font "$FUENTE_TEXTO" \
+ \( -fill "#FFFFFF" -pointsize "$cuerpo_titular" -gravity center label:"$titular" \) \
+ \( -fill "#CFE8DC" -pointsize "$cuerpo_subtitulo" -gravity center label:"$subtitulo" \
+ -gravity north -splice "0x$((cuerpo_titular / 2))" \) \
+ -gravity center -append +repage "$tmp/texto.png"
+
+ magick "$tmp/compuesto.png" "$tmp/texto.png" -gravity north \
+ -geometry "$(printf "%+d%+d" "$(calc "$ancho * ($TEXTO_CENTRO_X - 1)")" "$(calc "$alto * $TEXTO_ARRIBA")")" \
+ -composite "$tmp/final.png"
+
+ # 6. Cortar en dos: cada mitad es una captura de la ficha.
+ magick "$tmp/final.png" -crop "${ancho}x${alto}+0+0" +repage "$dir/${prefijo}01_bienvenida_framed.png"
+ magick "$tmp/final.png" -crop "${ancho}x${alto}+${ancho}+0" +repage "$dir/${prefijo}02_bienvenida_framed.png"
+
+ rm -rf "$tmp"
+ echo "✅ ${prefijo}01_bienvenida_framed.png y ${prefijo}02_bienvenida_framed.png en $dir"
+}
+
+PLATAFORMA="${1:-all}"
+case "$PLATAFORMA" in
+ ios|android|all) ;;
+ *) echo "❌ Uso: $(basename "$0") [ios|android|all]" >&2; exit 1 ;;
+esac
+
+[ "$PLATAFORMA" = android ] || componer "fastlane/screenshots" "es-MX"
+[ "$PLATAFORMA" = ios ] || componer "android/fastlane/screenshots" "es-419" "android"
diff --git a/scripts/generate-screenshot-backgrounds b/scripts/generate-screenshot-backgrounds
new file mode 100755
index 0000000..e76195b
--- /dev/null
+++ b/scripts/generate-screenshot-backgrounds
@@ -0,0 +1,80 @@
+#!/usr/bin/env bash
+# Genera los fondos de las capturas a partir de una sola foto: la desenfoca y la corta
+# en tiras verticales, una por captura. Puestas lado a lado en la ficha de la tienda,
+# las capturas reconstruyen la foto completa.
+#
+# Uso: scripts/generate-screenshot-backgrounds [ios|android|all]
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+FUENTE="fastlane/screenshots/background-source.jpg"
+BRILLO=-12 # Oscurecido general.
+DESENFOQUE=5 # Radio del blur, aplicado antes de ampliar (la ampliación suaviza aún más).
+VELO=45 # % del alto que cubre el degradado oscuro superior, bajo el título.
+
+command -v magick >/dev/null || { echo "❌ Falta ImageMagick (brew install imagemagick)" >&2; exit 1; }
+[ -f "$FUENTE" ] || { echo "❌ No existe $FUENTE" >&2; exit 1; }
+
+# Recorta la foto al lienzo pedido (cubriendo, centrada) y la desenfoca. El desenfoque
+# se hace en pequeño y luego se amplía: mismo resultado, mucho más rápido.
+panoramica() {
+ local ancho=$1 alto=$2 destino=$3
+ local alto_previo=$((1280 * alto / ancho))
+ magick "$FUENTE" \
+ -resize "1280x${alto_previo}^" -gravity center -extent "1280x${alto_previo}" \
+ -blur "0x${DESENFOQUE}" \
+ -brightness-contrast "${BRILLO}x-10" \
+ -resize "${ancho}x${alto}!" \
+ \( -size "${ancho}x$((alto * VELO / 100))" gradient:"#000000D9-#00000000" \) \
+ -gravity north -composite \
+ -quality 88 "$destino"
+}
+
+# generar
+generar() {
+ local base=$1 locale=$2 ancho=$3 alto=$4
+
+ # Las capturas ya tomadas mandan sobre el tamaño por defecto: si el alto no calza
+ # con el de la captura, frameit reescala y recorta el fondo y se pierde la continuidad.
+ local muestra
+ muestra=$(find "$base/$locale" -name "*.png" ! -name "*_framed.png" | sort | head -1 || true)
+ if [ -n "$muestra" ]; then
+ read -r ancho alto <<<"$(magick identify -format "%w %h" "$muestra")"
+ fi
+
+ # El orden lo define title.strings, que es el mismo con el que se suben las capturas.
+ local claves=()
+ while IFS= read -r clave; do claves+=("$clave"); done \
+ < <(grep -o '^"[^"]*"' "$base/$locale/title.strings" | tr -d '"')
+ local n=${#claves[@]}
+ [ "$n" -gt 0 ] || { echo "❌ $base/$locale/title.strings no tiene claves" >&2; exit 1; }
+
+ local tiras="$base/backgrounds"
+ rm -rf "$tiras" && mkdir -p "$tiras"
+
+ local panoramica_tmp="$tiras/.panoramica.jpg"
+ panoramica $((ancho * n)) "$alto" "$panoramica_tmp"
+
+ local i=0
+ for clave in "${claves[@]}"; do
+ magick "$panoramica_tmp" -crop "${ancho}x${alto}+$((i * ancho))+0" +repage \
+ -quality 88 "$tiras/${clave}.jpg"
+ i=$((i + 1))
+ done
+ rm -f "$panoramica_tmp"
+
+ # Fondo por defecto del Framefile: una captura sin entrada propia en `data` cae acá.
+ panoramica "$ancho" "$alto" "$base/background.jpg"
+
+ echo "✅ $n fondos de ${ancho}x${alto} en $tiras"
+}
+
+PLATAFORMA="${1:-all}"
+case "$PLATAFORMA" in
+ ios|android|all) ;;
+ *) echo "❌ Uso: $(basename "$0") [ios|android|all]" >&2; exit 1 ;;
+esac
+
+[ "$PLATAFORMA" = android ] || generar "fastlane/screenshots" "es-MX" 1320 2868
+[ "$PLATAFORMA" = ios ] || generar "android/fastlane/screenshots" "es-419" 1080 2160
diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart
new file mode 100644
index 0000000..965c407
--- /dev/null
+++ b/test_driver/integration_test.dart
@@ -0,0 +1,25 @@
+import "dart:io";
+
+import "package:integration_test/integration_test_driver_extended.dart";
+
+/// Driver de `flutter drive`: guarda en disco cada captura tomada con
+/// `binding.takeScreenshot()` desde integration_test/screenshots_test.dart.
+///
+/// Se configura con variables de entorno (las define el lane `ios screenshots`):
+/// - SCREENSHOTS_DIR: carpeta destino (por defecto fastlane/screenshots/es-MX)
+/// - SCREENSHOT_PREFIX: prefijo del archivo, usado para separar por dispositivo
+Future main() async {
+ final directorio = Platform.environment["SCREENSHOTS_DIR"] ?? "fastlane/screenshots/es-MX";
+ final prefijo = Platform.environment["SCREENSHOT_PREFIX"] ?? "";
+
+ await integrationDriver(
+ onScreenshot: (String nombre, List bytes, [Map? args]) async {
+ final archivo = File("$directorio/$prefijo$nombre.png");
+ await archivo.parent.create(recursive: true);
+ await archivo.writeAsBytes(bytes);
+ // ignore: avoid_print
+ print("📸 ${archivo.path}");
+ return true;
+ },
+ );
+}