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; + }, + ); +}