diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index df359eb..b614fb9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -223,218 +223,6 @@ 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/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml new file mode 100644 index 0000000..325c219 --- /dev/null +++ b/.github/workflows/screenshots.yml @@ -0,0 +1,565 @@ +# Las capturas viven en su propio workflow y no dentro del despliegue: corren en paralelo +# con él, 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, porque las capturas salen +# de una compilación de debug). +# +# Cada plataforma va en su propio runner: el simulador de iOS y la app de macOS obligan a un +# runner de macOS, y el emulador de Android necesita virtualización anidada. Los runners de +# macOS son máquinas virtuales sobre Apple Silicon y ahí adentro Hypervisor.framework no +# está disponible, así que el emulador —da igual si la imagen es arm64— muere apenas +# arranca con "HVF error: HV_UNSUPPORTED" y la acción se queda esperando un boot que nunca +# llega. En Linux sí hay KVM, y de paso los recorridos corren en paralelo. +# +# El recorrido de cada plataforma sale de su propia herramienta de fastlane: snapshot (UI +# Tests de Xcode) en iOS y iPad, screengrab (tests instrumentados) en Android y +# `flutter drive` en macOS, que es la única que ninguna de las dos cubre. +name: Capturas de pantalla +on: + push: + branches: [dev, prod] + # Al abrir la PR y al sacarla de borrador, para ver en la propia PR cómo queda la app. + # `synchronize` no está a propósito: cada corrida se lleva runners de macOS por casi una + # hora y no vale la pena repetirla en cada push a la rama. Para volver a generarlas está + # el comentario de abajo. + pull_request: + types: [opened, ready_for_review] + branches: [dev, prod] + # Comentando en la PR "@miutem screenshot " (sin argumento, all). + # El filtro va en el `if` de determine-environment, porque acá el evento llega por + # cualquier comentario del repositorio. + issue_comment: + types: [created] + workflow_dispatch: + inputs: + track: + description: "Selecciona el track del que se toman las capturas" + required: true + default: "dev" + type: choice + options: + - prod + - dev +jobs: + determine-environment: + # Los bumps de dependabot no generan capturas. Al saltarse este job, los screenshots-* + # que dependen de él también se saltan. + # + # De los comentarios sólo pasan los que piden capturas en una PR, y sólo si quien + # comenta tiene permiso de escritura: un comentario corre con los secretos del repo y + # sobre el código de la rama de la PR, así que no puede quedar abierto a cualquiera. + if: >- + ${{ github.actor != 'dependabot[bot]' + && github.event.head_commit.author.username != 'dependabot[bot]' + && (github.event_name != 'issue_comment' + || (github.event.issue.pull_request + && contains(github.event.comment.body, '@miutem screenshot') + && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))) }} + runs-on: ubuntu-latest + permissions: + # Para dejar la reacción con la que se acusa recibo del comando. + issues: write + outputs: + environment: ${{ steps.set-environment.outputs.environment }} + plataformas: ${{ steps.set-environment.outputs.plataformas }} + steps: + - name: Determinar entorno y plataformas + id: set-environment + env: + # Por variable de entorno y no interpolado en el script: el cuerpo del comentario + # lo escribe quien sea y dentro del `run` sería una inyección de shell. + COMENTARIO: ${{ github.event.comment.body }} + EVENTO: ${{ github.event_name }} + run: | + if [[ "${{ inputs.track }}" == "production" || "${{ github.ref }}" == "refs/heads/prod" ]]; then + echo "environment=prod" >> $GITHUB_OUTPUT + else + echo "environment=dev" >> $GITHUB_OUTPUT + fi + + # Se capturan todas las plataformas salvo que el comando pida una sola. + plataformas=all + if [[ "$EVENTO" == "issue_comment" ]]; then + if [[ "$COMENTARIO" =~ @miutem[[:space:]]+screenshot([[:space:]]+(ios|macos|android|all))? ]]; then + plataformas="${BASH_REMATCH[2]:-all}" + fi + fi + + echo "Plataformas a capturar: $plataformas" + echo "plataformas=$plataformas" >> $GITHUB_OUTPUT + + - name: 👀 Acusar recibo del comando + # El run de un comentario no aparece entre los checks de la PR, así que sin esto + # quien lo pide se queda sin saber si se tomó hasta que llega el comentario final. + if: ${{ github.event_name == 'issue_comment' }} + run: | + curl -sS -X POST \ + -H "Authorization: Bearer ${{ github.token }}" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${{ github.api_url }}/repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" \ + -d '{"content":"eyes"}' > /dev/null + + screenshots-ios: + needs: determine-environment + runs-on: macos-latest + environment: ${{ needs.determine-environment.outputs.environment }} + # Un runner por dispositivo: hechos uno tras otro el job se iba a hora y media, y son dos + # recorridos independientes. fail-fast apagado para que un simulador que falle no cancele + # el otro, que igual sirve para la ficha. + strategy: + fail-fast: false + matrix: + include: + # Los mismos dos que trae por defecto el lane, uno en cada runner. Por qué esos + # modelos y no otros está explicado ahí. + - dispositivo: iPhone 17 Pro Max + artefacto: iphone + - dispositivo: iPad Air 13-inch + artefacto: ipad + # Un recorrido se lleva ~25 minutos, más la compilación y la preparación del runner. + timeout-minutes: 60 + # Si contiene [skip deploy] o [skip screenshots] en el mensaje del commit, se saltará. + # `plataformas` sale del comando del comentario; en el resto de los eventos es `all`. + if: >- + ${{ !contains(github.event.head_commit.message, '[skip deploy]') + && !contains(github.event.head_commit.message, '[skip screenshots]') + && contains(fromJSON('["all", "ios"]'), needs.determine-environment.outputs.plataformas) }} + env: + # Los títulos de las capturas llevan tildes y el runner de macOS no define locale: + # frameit se cae al leer los .strings y termina sin enmarcar ninguna captura. + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + steps: + - name: 📚 Clonar Repositorio + uses: actions/checkout@v4 + with: + # El run de un comentario sale de la rama por defecto: hay que traer el código de + # la PR a la que se le pidieron las capturas. En el resto de los eventos, vacío + # deja el checkout como viene. + ref: ${{ github.event_name == 'issue_comment' && format('refs/pull/{0}/head', github.event.issue.number) || '' }} + + - 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-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }} + restore-keys: pub-cache-screenshots-${{ runner.os }}- + + - name: ⚡ Configurar Cache para CocoaPods + uses: actions/cache@v5 + with: + path: | + ios/Pods + ~/.cocoapods + key: pods-screenshots-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }} + restore-keys: pods-screenshots-${{ 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 }} + # Debug porque las capturas salen de una compilación de debug, y `all` porque el + # lane elige el flavor según APP_ENV: los dos tienen que tener sus credenciales. + run: ./scripts/flutterfire-configure all --build-type=Debug + + - name: 📸 Generar capturas de iOS + run: bundle exec fastlane ios screenshots + env: + APP_ENV: ${{ vars.APP_ENV }} + SCREENSHOT_DEVICES: ${{ matrix.dispositivo }} + + - name: 📤 Subir capturas como artefacto + id: artefacto + uses: actions/upload-artifact@v7 + with: + name: capturas-${{ matrix.artefacto }} + path: fastlane/screenshots/*/*.png + if-no-files-found: error + + - name: 📝 Publicar capturas en el resumen + env: + ARTEFACTO_URL: ${{ steps.artefacto.outputs.artifact-url }} + DISPOSITIVO: ${{ matrix.dispositivo }} + run: | + { + echo "## 📸 Capturas de pantalla ($DISPOSITIVO)" + 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 + # Si contiene [skip deploy] o [skip screenshots] en el mensaje del commit, se saltará. + # `plataformas` sale del comando del comentario; en el resto de los eventos es `all`. + if: >- + ${{ !contains(github.event.head_commit.message, '[skip deploy]') + && !contains(github.event.head_commit.message, '[skip screenshots]') + && contains(fromJSON('["all", "android"]'), needs.determine-environment.outputs.plataformas) }} + env: + # Los títulos llevan tildes y frameit los lee con el encoding del entorno: si no es + # UTF-8 no falla, simplemente deja las capturas sin enmarcar. C.UTF-8 siempre existe + # en Ubuntu, a diferencia de en_US.UTF-8, que hay que generar. + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + steps: + - name: 📚 Clonar Repositorio + uses: actions/checkout@v4 + with: + # El run de un comentario sale de la rama por defecto: hay que traer el código de + # la PR a la que se le pidieron las capturas. En el resto de los eventos, vacío + # deja el checkout como viene. + ref: ${{ github.event_name == 'issue_comment' && format('refs/pull/{0}/head', github.event.issue.number) || '' }} + + - 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 Java + # Gradle compila el APK de las capturas; se fija el JDK en vez de depender del que + # traiga por defecto la imagen del runner, que cambia entre versiones. + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - 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 sigue en el 6 y deja únicamente + # `convert`. El runner ya trae Homebrew (instalado pero fuera del PATH) y su + # fórmula sí es la 7, así que se instala igual que en macOS y se enlazan sólo los + # binarios, para no meter todo el prefijo de brew delante del PATH del resto del job. + # + # `convert` va aunque nadie lo llame: es el binario de ImageMagick 6 y frameit lo + # sigue buscando para decidir si tiene con qué trabajar, así que sin él el + # `fastlane frameit` de scripts/compose-store-screenshots se niega a partir. + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + HOMEBREW_NO_INSTALL_CLEANUP: "1" + run: | + eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" + brew install imagemagick + for binario in magick convert; do + sudo ln -sf "$HOMEBREW_PREFIX/bin/$binario" "/usr/local/bin/$binario" + done + # Por la ruta enlazada, que es la que ven los pasos siguientes (sin el PATH de brew). + /usr/local/bin/magick -version + /usr/local/bin/convert -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 }} + # Debug porque las capturas salen de una compilación de debug, y `all` porque el + # lane elige el flavor según APP_ENV: los dos tienen que tener sus credenciales. + run: ./scripts/flutterfire-configure all --build-type=Debug + + - name: 📸 Generar capturas de Android + # 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" + + screenshots-macos: + 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á. + # `plataformas` sale del comando del comentario; en el resto de los eventos es `all`. + if: >- + ${{ !contains(github.event.head_commit.message, '[skip deploy]') + && !contains(github.event.head_commit.message, '[skip screenshots]') + && contains(fromJSON('["all", "macos"]'), needs.determine-environment.outputs.plataformas) }} + env: + # Los títulos de las capturas llevan tildes y el runner de macOS no define locale. + LANG: en_US.UTF-8 + LC_ALL: en_US.UTF-8 + steps: + - name: 📚 Clonar Repositorio + uses: actions/checkout@v4 + with: + # El run de un comentario sale de la rama por defecto: hay que traer el código de + # la PR a la que se le pidieron las capturas. En el resto de los eventos, vacío + # deja el checkout como viene. + ref: ${{ github.event_name == 'issue_comment' && format('refs/pull/{0}/head', github.event.issue.number) || '' }} + + - 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-macos-${{ runner.os }}-${{ hashFiles('**/pubspec.lock') }} + restore-keys: pub-cache-screenshots-macos-${{ runner.os }}- + + - name: ⚡ Configurar Cache para CocoaPods + uses: actions/cache@v5 + with: + path: | + macos/Pods + ~/.cocoapods + key: pods-screenshots-macos-${{ runner.os }}-${{ hashFiles('macos/Podfile.lock') }} + restore-keys: pods-screenshots-macos-${{ runner.os }}- + + - name: 🪄 Instalar ImageMagick + # Los scripts de fondos y de enmarcado 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: el recorrido de macOS lo ejecuta `flutter drive`, que compila en + # debug, e 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 macOS + run: bundle exec fastlane mac screenshots + env: + APP_ENV: ${{ vars.APP_ENV }} + IS_CI: ${{ vars.IS_CI }} + + - name: 📤 Subir capturas como artefacto + id: artefacto + uses: actions/upload-artifact@v7 + with: + name: capturas-macos + path: macos/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 (macOS)" + echo "" + echo "- $(find macos/fastlane/screenshots -name '*_framed.png' | wc -l | tr -d ' ') capturas enmarcadas de 2880x1800 para la Mac App Store" + echo "- [Descargar artefacto]($ARTEFACTO_URL)" + } >> "$GITHUB_STEP_SUMMARY" + + # Deja en la PR el detalle de lo que salió en cada plataforma. Las imágenes no se pueden + # mostrar embebidas —GitHub no deja adjuntar archivos a un comentario desde la API—, así + # que el comentario lleva la lista de capturas y el enlace para descargar el artefacto. + # + # Va en un job aparte y no dentro de cada plataforma para dejar un solo comentario por + # corrida, en vez de cuatro compitiendo por el mismo hilo. + comentario-en-pr: + needs: [screenshots-ios, screenshots-android, screenshots-macos] + runs-on: ubuntu-latest + # `always()` para comentar también cuando una plataforma falla: saber cuál se cayó es + # parte de lo que se revisa en la PR. En PRs desde un fork no corre, porque ahí el + # token es de sólo lectura y tampoco hay secretos con los que generar las capturas; en + # los comentarios el token sí puede escribir, y quién puede pedirlas ya lo filtró + # determine-environment. + if: >- + ${{ always() + && (github.event_name == 'issue_comment' + || (github.event_name == 'pull_request' + && github.event.pull_request.head.repo.full_name == github.repository)) }} + permissions: + actions: read + contents: read + pull-requests: write + steps: + - name: 📚 Clonar Repositorio + uses: actions/checkout@v4 + with: + # El run de un comentario sale de la rama por defecto: hay que traer el código de + # la PR a la que se le pidieron las capturas. En el resto de los eventos, vacío + # deja el checkout como viene. + ref: ${{ github.event_name == 'issue_comment' && format('refs/pull/{0}/head', github.event.issue.number) || '' }} + + # Un paso por artefacto en vez de un `pattern`: cuando calza uno solo, + # download-artifact lo extrae directamente en `path` sin crearle su carpeta, y el + # comentario terminaba diciendo que no se generó ninguna captura teniéndola al lado. + # Pidiéndolo por nombre la ruta es siempre la misma. + # + # `continue-on-error` en todos: la plataforma que falla no deja artefacto, y el + # comentario igual tiene que salir para contar cuál se cayó. + - name: 📥 Descargar las capturas de iPhone + uses: actions/download-artifact@v7 + continue-on-error: true + with: + name: capturas-iphone + path: capturas/capturas-iphone + + - name: 📥 Descargar las capturas de iPad + uses: actions/download-artifact@v7 + continue-on-error: true + with: + name: capturas-ipad + path: capturas/capturas-ipad + + - name: 📥 Descargar las capturas de Android + uses: actions/download-artifact@v7 + continue-on-error: true + with: + name: capturas-android + path: capturas/capturas-android + + - name: 📥 Descargar las capturas de macOS + uses: actions/download-artifact@v7 + continue-on-error: true + with: + name: capturas-macos + path: capturas/capturas-macos + + - name: 💬 Comentar en la PR + env: + GITHUB_TOKEN: ${{ github.token }} + # En un comentario la PR llega como issue: son el mismo número. + PR_NUMERO: ${{ github.event.pull_request.number || github.event.issue.number }} + RESULTADO_IOS: ${{ needs.screenshots-ios.result }} + RESULTADO_ANDROID: ${{ needs.screenshots-android.result }} + RESULTADO_MACOS: ${{ needs.screenshots-macos.result }} + run: ./scripts/pr-screenshots-comment capturas diff --git a/Gemfile.lock b/Gemfile.lock index d920df1..94e71cb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -25,7 +25,7 @@ GEM specs: CFPropertyList (3.0.8) abbrev (0.1.2) - activesupport (7.2.3.1) + activesupport (7.2.3.2) base64 benchmark (>= 0.3) bigdecimal @@ -45,8 +45,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1271.0) - aws-sdk-core (3.254.0) + aws-partitions (1.1280.0) + aws-sdk-core (3.254.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -57,8 +57,8 @@ GEM aws-sdk-kms (1.130.0) aws-sdk-core (~> 3, >= 3.254.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.228.0) - aws-sdk-core (~> 3, >= 3.254.0) + aws-sdk-s3 (1.229.0) + aws-sdk-core (~> 3, >= 3.254.1) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -94,7 +94,7 @@ GEM highline (~> 2.0.0) concurrent-ruby (1.3.8) connection_pool (3.0.2) - csv (3.3.5) + csv (3.3.6) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) @@ -102,41 +102,29 @@ GEM dotenv (2.8.1) drb (2.2.3) emoji_regex (3.2.3) + erb (6.0.7) ethon (0.18.0) ffi (>= 1.15.0) logger - excon (1.6.0) + excon (1.7.0) + logger + faraday (2.14.3) + faraday-net_http (>= 2.0, < 3.5) + json logger - faraday (1.10.6) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) http-cookie (>= 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) faraday-multipart (1.2.0) multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.4) - faraday_middleware (1.2.1) - faraday (~> 1.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) fastimage (2.4.1) - fastlane (2.237.0) + fastlane (2.238.0) CFPropertyList (>= 2.3, < 5.0.0) abbrev (~> 0.1) addressable (>= 2.9.0, < 3.0.0) @@ -152,9 +140,11 @@ GEM dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 2.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) fastimage (>= 2.1.0, < 3.0.0) fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) @@ -164,6 +154,7 @@ GEM google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) + irb (>= 1.8) json (< 3.0.0) jwt (>= 2.10.3, < 4) logger (>= 1.6, < 2.0) @@ -180,7 +171,7 @@ GEM security (= 0.1.5) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (~> 3) + terminal-table (~> 4) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -193,16 +184,17 @@ GEM fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.105.0) + google-apis-androidpublisher_v3 (0.106.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-core (0.18.0) - addressable (~> 2.5, >= 2.5.1) - googleauth (~> 1.9) - httpclient (>= 2.8.3, < 3.a) - mini_mime (~> 1.0) - mutex_m + google-apis-core (1.2.5) + addressable (~> 2.9) + faraday (~> 2.13) + faraday-follow_redirects (~> 0.3) + googleauth (~> 1.14) + mini_mime (~> 1.1) + multi_json (~> 1.11) representable (~> 3.0) - retriable (>= 2.0, < 4.a) + retriable (>= 3.1, < 5.0) google-apis-iamcredentials_v1 (0.28.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-playcustomapp_v1 (0.18.0) @@ -226,7 +218,7 @@ GEM googleauth (~> 1.9) mini_mime (~> 1.0) google-logging-utils (0.2.0) - googleauth (1.17.1) + googleauth (1.17.3) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) google-logging-utils (~> 0.1) @@ -241,9 +233,14 @@ GEM mutex_m i18n (1.15.2) concurrent-ruby (~> 1.0) - io-console (0.8.2) + io-console (0.9.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jmespath (1.6.2) - json (2.21.1) + json (2.21.2) jwt (3.2.0) base64 logger (1.7.0) @@ -258,12 +255,18 @@ GEM nanaimo (0.4.0) nap (1.1.0) naturally (2.3.0) + net-http (0.9.1) + uri (>= 0.11.1) netrc (0.11.0) nkf (0.3.0) optparse (0.8.1) os (1.1.4) ostruct (0.6.3) plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) pry (0.16.0) coderay (~> 1.1) method_source (~> 1.0) @@ -271,17 +274,25 @@ GEM pstore (0.2.1) public_suffix (4.0.7) rake (13.4.2) - reline (0.6.3) + rbs (4.1.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.8.0) + retriable (4.2.0) rexml (3.4.4) rouge (3.28.0) ruby-macho (4.1.0) - ruby2_keywords (0.0.5) rubyzip (2.4.1) securerandom (0.4.1) security (0.1.5) @@ -293,9 +304,10 @@ GEM CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) trailblazer-option (0.1.2) + tsort (0.2.0) tty-cursor (0.7.1) tty-screen (0.8.2) tty-spinner (0.9.3) @@ -305,7 +317,10 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) - unicode-display_width (2.6.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) word_wrap (1.0.0) xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) @@ -336,16 +351,16 @@ DEPENDENCIES CHECKSUMS CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261 abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242 - activesupport (7.2.3.1) sha256=11ebed516a43a0bb47346227a35ebae4d9427465a7c9eb197a03d5c8d283cb34 + activesupport (7.2.3.2) sha256=ddc90d11dd88086d78ace03407fe3c2a3f5c8853c3c3046313db5ed823ef3312 addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af algoliasearch (1.27.5) sha256=26c1cddf3c2ec4bd60c148389e42702c98fdac862881dc6b07a4c0b89ffec853 artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b - aws-partitions (1.1271.0) sha256=a078db0fa59bb5beeceef56b3a482140179ece702ee94fbc94196e2281296c0c - aws-sdk-core (3.254.0) sha256=ee3e3220b8468a3c9e59daba18e6ec897bf5c7ce8adcc0670cfa2f1f092112fe + aws-partitions (1.1280.0) sha256=4c5f5f0765603a738d6e212ae228ae6e5129696e907e4a2854c03d55ff9524bc + aws-sdk-core (3.254.1) sha256=518089e32134c3478cd4ec63d07fb966546a45e9e4cbf5f80d2cf16d5699d29b aws-sdk-kms (1.130.0) sha256=a2e83662ca31b77a2a19c9aa2f40a98165a67270c18c718fe1c70d0cbd7cd749 - aws-sdk-s3 (1.228.0) sha256=9b0cd7655d32643e2969030acbfa67326afcd5fd91325239a4ad5f3365f0f801 + aws-sdk-s3 (1.229.0) sha256=7dd11a111875b3505d2ec0a0a383a6aab6c1badb3d4e94e7fbb958a24451f596 aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b @@ -366,38 +381,32 @@ CHECKSUMS commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9 concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a - csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9 digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07 domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 ethon (0.18.0) sha256=b598afc9f30448cb068b850714b7d6948e941476095d04f90a4ac65b8d6efcb2 - excon (1.6.0) sha256=ebe2ab83c055ef8e117c62c91a3535b966a59a64868188219d49ab90cd83b65f - faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d + excon (1.7.0) sha256=b8deec2bf978123b7e4b13a103ffec61557c6feaba34706dbc1ce4a94dec33e0 + faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278 faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb - faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 - faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 - faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940 - faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b + faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 - faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682 - faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335 - faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7 - faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0 - faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa - faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 + faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588 + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 - fastlane (2.237.0) sha256=bb1e867bc070fb328741b5e6e7606d5ba596941a9ecca0478f60ef22d1009db9 + fastlane (2.238.0) sha256=78e9252df2224c7e427012638e41051edfa29b133a40fda883fd2f1c13a77b98 fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b fourflusher (2.3.1) sha256=1b3de61c7c791b6a4e64f31e3719eb25203d151746bb519a0292bff1065ccaa9 fuzzy_match (2.0.4) sha256=b5de4f95816589c5b5c3ad13770c0af539b75131c158135b3f3bbba75d0cfca5 gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 - google-apis-androidpublisher_v3 (0.105.0) sha256=31afbbf2512def8f6605238554d1a1274158539374e1d602a37bce41fc07a6b4 - google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee + google-apis-androidpublisher_v3 (0.106.0) sha256=b90b5312b70f8f731def88f24a2b8fb791fe1b979a7c2c14a905cb6cae19cf3f + google-apis-core (1.2.5) sha256=e21562b5627a0fc6a0c3165e429c3afed0f6a628eaa514e83f7204dda1adff69 google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106 google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254 google-apis-storage_v1 (0.65.0) sha256=4247aa542931691f6988ab61e45cdea2878cbfe897a98eec4af9cdcb678eb359 @@ -406,14 +415,15 @@ CHECKSUMS google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3 google-cloud-storage (1.62.0) sha256=e2c3c08bf8fd40d50be92304084942203314d4fc0ee52028e99f9359c3ad1330 google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b - googleauth (1.17.1) sha256=0f7e6fc70e204cee1b2d71f1e1de2d3b349d432404197fe68ebf7fa23d0821b9 + googleauth (1.17.3) sha256=9a655455885c0c17a8b70537acd2a992a7c7842c43c06c6612dfb0d93e755d58 highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 - io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 - json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 method_source (1.1.0) sha256=181301c9c45b731b4769bc81e8860e72f9161ad7d66dd99103c9ab84f560f5c5 @@ -427,38 +437,46 @@ CHECKSUMS nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 nap (1.1.0) sha256=949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576 naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 pry (0.16.0) sha256=d76c69065698ed1f85e717bd33d7942c38a50868f6b0673c636192b3d1b6054e pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8 rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 - reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rbs (4.1.3) sha256=0c4474a9751cdc14364bfad0b3e53678323bbdc2c31683b0445932867dbab8c4 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace - retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5 + retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 ruby-macho (4.1.0) sha256=23dab37f7de0fe1e14f3bfa73bebc423ae8cd1d4fdb3e5585abc45a841eca920 - ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780 simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea - terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2 trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 typhoeus (1.6.0) sha256=bacc41c23e379547e29801dc235cd1699b70b955a1ba3d32b2b877aa844c331d tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc - unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2a0e189..3e53add 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -48,8 +48,6 @@ android { ?: (keystoreProperties["keyPassword"] as String?) val useDebugSigning = System.getenv("MIUTEM_USE_DEBUG_SIGNING")?.toBoolean() ?: false - val debugKeystore = file("${System.getProperty("user.home")}/.android/debug.keystore") - signingConfigs { create("release") { keyAlias = resolvedKeyAlias @@ -57,21 +55,32 @@ android { storeFile = resolvedStoreFile?.let { file(it) } storePassword = resolvedStorePassword } + // Sin keystore propio se deja el `debug` tal cual lo trae AGP, que apunta a + // ~/.android/debug.keystore y lo crea la primera vez que lo necesita. Fijarle el + // `storeFile` a mano lo convierte en un keystore del usuario y Gradle exige que ya + // exista: en una máquina limpia —los runners de CI lo son— el build muere con + // "Keystore file '~/.android/debug.keystore' not found for signing config 'debug'". getByName("debug") { - keyAlias = if (useDebugSigning) "androiddebugkey" else (resolvedKeyAlias ?: "androiddebugkey") - keyPassword = if (useDebugSigning) "android" else (resolvedKeyPassword ?: "android") - storeFile = if (useDebugSigning) debugKeystore else (resolvedStoreFile?.let { file(it) } ?: debugKeystore) - storePassword = if (useDebugSigning) "android" else (resolvedStorePassword ?: "android") + if (!useDebugSigning && resolvedStoreFile != null) { + keyAlias = resolvedKeyAlias ?: "androiddebugkey" + keyPassword = resolvedKeyPassword ?: "android" + storeFile = file(resolvedStoreFile) + storePassword = resolvedStorePassword ?: "android" + } } } defaultConfig { applicationId = "cl.inndev.miutem" - + minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + + // El runner con el que screengrab ejecuta el recorrido de las capturas. + // https://docs.fastlane.tools/getting-started/android/screenshots/ + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildFeatures { @@ -106,3 +115,19 @@ android { flutter { source = "../.." } + +dependencies { + // Capturas de pantalla para Google Play. La dependencia es la que pide la guía de + // fastlane: https://docs.fastlane.tools/getting-started/android/screenshots/ + androidTestImplementation("tools.fastlane:screengrab:2.1.1") + + // El POM de screengrab declara todas sus dependencias con alcance de ejecución, así que + // están en el APK de tests pero no al compilar el recorrido. JUnit y el registro de + // instrumentación igual se resuelven porque el plugin integration_test los expone por + // el classpath de la app; UI Automator no lo trae nadie más y hay que pedirlo acá. + // + // En la misma versión que ya resuelve screengrab: AGP alinea el classpath de + // compilación de los tests con el de ejecución, y pedir otra versión termina en + // "cannot find a version ... by consistent resolution". + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.2.0") +} diff --git a/android/app/src/androidTest/AndroidManifest.xml b/android/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..30dd252 --- /dev/null +++ b/android/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/android/app/src/androidTest/kotlin/cl/inndev/miutem/ScreenshotsTest.kt b/android/app/src/androidTest/kotlin/cl/inndev/miutem/ScreenshotsTest.kt new file mode 100644 index 0000000..1df897a --- /dev/null +++ b/android/app/src/androidTest/kotlin/cl/inndev/miutem/ScreenshotsTest.kt @@ -0,0 +1,307 @@ +package cl.inndev.miutem + +import android.content.Intent +import android.os.SystemClock +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import java.util.regex.Pattern +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import tools.fastlane.screengrab.Screengrab +import tools.fastlane.screengrab.locale.LocaleTestRule + +/** + * Recorrido de las capturas de Google Play, tal como lo pide la guía de fastlane para + * Android: un test instrumentado con JUnit4, la regla de idiomas de screengrab y una + * llamada a `Screengrab.screenshot(...)` en cada pantalla. + * https://docs.fastlane.tools/getting-started/android/screenshots/ + * + * Lo ejecuta `bundle exec fastlane android screenshots`. + * + * Las pantallas son de Flutter, así que no hay ids de vistas contra los que apuntar con + * Espresso: la app se recorre por el árbol de accesibilidad con UI Automator, que es el + * mismo que usa screengrab para tomar la captura. + * + * El lane compila la app con `--dart-define=SCREENSHOT_MODE=true`, así que responde con + * los datos ficticios de `lib/core/mock/` y acepta cualquier usuario y clave. + */ +@RunWith(JUnit4::class) +class ScreenshotsTest { + + @Rule + @JvmField + val localeTestRule = LocaleTestRule() + + private val device: UiDevice + get() = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + /** + * Abre la app como lo haría el lanzador del sistema. + * + * No se usa ActivityScenarioRule, que es lo que muestra el ejemplo de la guía: vive en + * androidx.test.ext:junit y arrastra un androidx.test:core más nuevo que el runner + * 1.3.0 que trae screengrab. Como AGP alinea las dependencias de los tests con las de + * la app, runner no se puede subir, y con las versiones mezcladas ActivityScenario + * revienta con "AbstractMethodError: ActivityInvoker.getIntentForActivity". + */ + @Before + fun abrirLaApp() { + val contexto = InstrumentationRegistry.getInstrumentation().targetContext + val intent = requireNotNull(contexto.packageManager.getLaunchIntentForPackage(contexto.packageName)) { + "El paquete ${contexto.packageName} no tiene actividad de lanzamiento." + } + // Sin la tarea anterior: cada corrida parte de cero, igual que abriendo la app a mano. + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + contexto.startActivity(intent) + + if (device.wait(Until.hasObject(By.pkg(contexto.packageName).depth(0)), LIMITE) != true) { + throw AssertionError("La app no llegó a mostrarse después de ${LIMITE / 1000}s.") + } + } + + /** + * 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 ficha. + */ + @Test + fun capturaLasPantallasDeLaTienda() { + iniciarSesion() + + // 03 — el inicio, pero en oscuro: es la novedad que se está mostrando. + cambiarTema("Oscuro") + irAPestana("Inicio") + Screengrab.screenshot("03_oscuro") + cambiarTema("Claro") + + // 04 — la calculadora de notas, que cuelga de los accesos rápidos de Asignaturas. + irAPestana("Asignaturas") + abrirAccesoRapido("Notas") + Screengrab.screenshot("04_calculadora") + volver() + + // 05 — el inicio en claro, con la primera clase del día en curso. + irAPestana("Inicio") + Screengrab.screenshot("05_inicio") + + // 06 — la credencial con el código QR. + irAPestana("Credencial") + Screengrab.screenshot("06_credencial") + + // 07 y 08 — horario y malla histórica, también desde los accesos rápidos. + irAPestana("Asignaturas") + for ((acceso, archivo) in listOf("Horario" to "07_horario", "Malla Histórica" to "08_malla")) { + abrirAccesoRapido(acceso) + Screengrab.screenshot(archivo) + volver() + } + } + + // ---- Recorrido ---- + + private fun iniciarSesion() { + escribir(0, USUARIO) + escribir(1, CLAVE) + + // Se envía con la tecla de acción del teclado en vez de tocar el botón. El teclado + // tapa la mitad de abajo de la pantalla, que es donde está "Ingresar", y UI + // Automator no lo sabe: calcula lo que se ve de un nodo contra los límites de la + // ventana de la app, sin considerar que el teclado va encima, así que el toque + // aterriza en una tecla. En este formulario la acción del campo de usuario pasa el + // foco a la contraseña y la de la contraseña envía, que es el mismo camino que + // terminó funcionando en iOS. + device.pressEnter() + + // Sin camino de respaldo por el botón: para despejarlo habría que cerrar el + // teclado, y la tecla de acción ya lo cerró al enviar. `pressBack` ahí no cierra + // nada, sale de la app y deja el recorrido mirando el escritorio de Android. + esperar("la navegación principal", "Inicio") + esperarCarga() + } + + /** + * Escribe en el campo de texto que ocupa esa posición, tecleando. + * + * Se enfoca y se mandan las teclas en vez de usar la acción de accesibilidad, que sería + * más directa y no abriría el teclado: en un campo con la contraseña oculta no hay + * forma de leer de vuelta el valor para saber si la acción se atendió, y al intentarlo + * de las dos maneras el texto se termina escribiendo dos veces. + * + * El campo se vuelve a buscar en cada llamada porque escribir en el anterior rehace el + * árbol de accesibilidad y deja al otro apuntando a un nodo que ya no existe. + */ + private fun escribir(indice: Int, valor: String) { + esperarCampos()[indice].click() + SystemClock.sleep(PASO) + InstrumentationRegistry.getInstrumentation().sendStringSync(valor) + SystemClock.sleep(PASO) + } + + /** + * Cambia el tema pasando por Perfil → Pantalla → Tema, que es el único camino que hay + * desde afuera de la app: el test sólo puede tocar lo que se ve en pantalla. + */ + private fun cambiarTema(modo: String) { + irAPestana("Perfil") + tocar("el ajuste de tema", "Tema de la aplicación") + esperarCarga(2) + // Con el diálogo abierto Flutter deja de publicar la pantalla de atrás, así que la + // etiqueta del modo sólo puede ser la de la opción y no la del subtítulo del ajuste. + tocar("la opción de tema $modo", modo) + esperarCarga(3) + } + + private fun irAPestana(etiqueta: String) { + tocar("la pestaña $etiqueta", etiqueta) + esperarCarga() + } + + /** + * Abre una pantalla desde las tarjetas de acceso rápido. + * + * No hace falta acotar la búsqueda a Asignaturas aunque el inicio tenga accesos + * homónimos: la navegación mantiene montadas las dos pestañas, pero Flutter publica en + * el árbol de accesibilidad sólo la que se está mostrando. + */ + private fun abrirAccesoRapido(etiqueta: String) { + tocar("el acceso rápido $etiqueta", etiqueta) + esperarCarga() + } + + private fun volver() { + device.pressBack() + esperarCarga(3) + } + + // ---- Utilidades ---- + + /** + * Los campos del login, en el orden en que están en pantalla: usuario y contraseña. + * + * Flutter publica sus campos de texto con la clase de un EditText, y se buscan por ahí + * y no por su etiqueta porque en un campo de texto la etiqueta viaja pegada al valor y + * al hint, todo en el mismo texto del nodo. + */ + private fun esperarCampos(): List { + val fin = SystemClock.uptimeMillis() + LIMITE + while (SystemClock.uptimeMillis() < fin) { + val campos = device.findObjects(By.clazz("android.widget.EditText")) + if (campos.size >= 2) { + return campos + } + SystemClock.sleep(PASO) + } + + throw AssertionError( + "No apareció el login (¿falta --dart-define=SCREENSHOT_MODE=true?) después de ${LIMITE / 1000}s. ${loQueSeVe()}" + ) + } + + /** + * Lo que hay en pantalla en el momento del fallo. + * + * Va pegado al mensaje del error y no a un log aparte porque el recorrido corre en el + * emulador de CI: lo único que llega de vuelta es el texto de la excepción, y sin esto + * un fallo sólo dice "no apareció X" sin distinguir si la app se quedó en el login, si + * mostró un error o si la etiqueta que se busca cambió de nombre. + */ + private fun loQueSeVe(): String { + // `By.text`/`By.desc` con patrón y no con texto: así traen todo lo que tenga algo + // escrito. DOTALL porque una etiqueta con salto de línea si no queda fuera. + val algo = Pattern.compile(".+", Pattern.DOTALL) + val textos = device.findObjects(By.text(algo)).mapNotNull { it.text } + val etiquetas = device.findObjects(By.desc(algo)).mapNotNull { it.contentDescription } + val visible = (textos + etiquetas).map { it.trim() }.filter { it.isNotEmpty() }.distinct() + + // Aparte, porque en la lista de arriba un campo vacío no aparece y es justamente lo + // que hay que saber cuando el login no avanza. + val campos = device.findObjects(By.clazz("android.widget.EditText")) + .joinToString(", ") { "«${it.text.orEmpty()}»" } + + val pantalla = if (visible.isEmpty()) { + "No hay ningún texto ni etiqueta en pantalla." + } else { + "En pantalla: ${visible.joinToString(" ┊ ").take(3000)}" + } + // El paquete primero: si no es el de la app, lo de abajo es de otra pantalla y no + // hay nada que buscarle sentido. + val donde = "Paquete: ${device.currentPackageName}" + return if (campos.isEmpty()) { + "$donde | $pantalla" + } else { + "$donde | $pantalla | Campos de texto: $campos" + } + } + + /** + * Busca un nodo por su etiqueta de accesibilidad. + * + * Se compara por contenido y no por igualdad porque Flutter junta en una sola etiqueta + * el título y el subtítulo de un mismo control, y se prefiere el nodo tocable: el + * mismo texto suele estar además como texto suelto dentro del control. + */ + private fun buscar(texto: String): UiObject2? { + // El patrón a mano en vez de `descContains`/`textContains`, que arman `^.*texto.*$` + // sin DOTALL: `.` no cruza saltos de línea y esas etiquetas los traen —una pestaña + // llega como "Inicio\nTab 1 of 5"—, así que ninguna pestaña era encontrable. + val patron = Pattern.compile(".*${Pattern.quote(texto)}.*", Pattern.DOTALL) + return device.findObject(By.clickable(true).desc(patron)) + ?: device.findObject(By.clickable(true).text(patron)) + ?: device.findObject(By.desc(patron)) + ?: device.findObject(By.text(patron)) + } + + private fun esperar(descripcion: String, texto: String): UiObject2 { + val fin = SystemClock.uptimeMillis() + LIMITE + while (SystemClock.uptimeMillis() < fin) { + buscar(texto)?.let { return it } + SystemClock.sleep(PASO) + } + + throw AssertionError("No apareció $descripcion después de ${LIMITE / 1000}s. ${loQueSeVe()}") + } + + /** + * Toca un nodo, desplazando la pantalla si quedó fuera de vista: Perfil publica todos + * sus ajustes en el árbol de accesibilidad, pero sólo se puede tocar lo que se ve. + */ + private fun tocar(descripcion: String, texto: String) { + var objeto = esperar(descripcion, texto) + + var intentos = 0 + while (objeto.visibleBounds.isEmpty && intentos < 6) { + device.swipe(device.displayWidth / 2, device.displayHeight * 3 / 4, device.displayWidth / 2, device.displayHeight / 4, 20) + SystemClock.sleep(PASO) + objeto = esperar(descripcion, texto) + intentos++ + } + + if (objeto.visibleBounds.isEmpty) { + throw AssertionError("$descripcion nunca quedó a la vista para tocarlo. ${loQueSeVe()}") + } + + objeto.click() + } + + /** + * Las pantallas cargan sus datos al abrirse —aunque sean los ficticios— y el cambio de + * pestaña se anima, así que se les da tiempo antes de capturar. + */ + private fun esperarCarga(segundos: Long = 8) = SystemClock.sleep(segundos * 1000) + + private companion object { + /** En modo capturas la sesión vive en memoria y el par se acepta tal cual. */ + const val USUARIO = "ecarrenos" + const val CLAVE = "utem2027" + + /** El emulador arranca frío y la app va en debug: las esperas son generosas. */ + const val LIMITE = 180_000L + const val PASO = 500L + } +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 4fa8d35..acb4cc2 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,18 +1,33 @@ - + + + - + - - + + + + diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile index 0500b41..13f2071 100644 --- a/android/fastlane/Fastfile +++ b/android/fastlane/Fastfile @@ -1,7 +1,21 @@ import "../fastlane/src/helpers.rb" +require "json" require "timeout" +# applicationId que AGP deja anotado junto al APK que acaba de compilar, o nil si no está. +# +# Es el nombre con el que el paquete queda instalado en el dispositivo, sufijos de flavor +# incluidos, y no tiene por qué coincidir con APP_PACKAGE_NAME: ese secreto es el de la +# ficha de Google Play. +def android_application_id(metadata) + return nil unless File.exist?(metadata) + + JSON.parse(File.read(metadata))["applicationId"] +rescue JSON::ParserError + nil +end + # 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") @@ -122,15 +136,22 @@ platform :android do output_dir = File.join(screenshots_dir, locale) play_dir = File.join(root, "android", "fastlane", "metadata", "android", locale, "images", "phoneScreenshots") + # screengrab deja las capturas con la forma que espera supply + # (/images/phoneScreenshots): se recogen de acá y se mueven junto al + # title.strings, que es donde trabajan frameit y los scripts de fondos. + # + # El nombre no puede contener "screengrab": al armar el destino, screengrab le quita a + # la ruta el trozo que coincida con `(app_)?screengrab/` —está pensado para el + # app_screengrab/ del dispositivo— y de paso se comería esta carpeta, dejando las + # capturas un nivel más arriba mientras el reporte HTML se escribe acá y no encuentra + # el directorio. + screengrab_dir = File.join(screenshots_dir, ".capturas") + 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 @@ -145,22 +166,57 @@ platform :android do 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. + # Los APK de las capturas son debug y no se distribuyen: se firman con la llave de + # debug para no depender del keystore de release que traiga el .env. Los dos tienen + # que quedar firmados igual o Android no deja instrumentar la app. ENV["MIUTEM_USE_DEBUG_SIGNING"] = "true" + # screengrab necesita el APK de la app y el de los tests, como dice la guía + # (`./gradlew assembleDebug assembleAndroidTest`). Acá el primero lo compila flutter, + # porque es el único que sabe de `--dart-define`, y el segundo gradle, que es quien + # arma el bundle de tests instrumentados. + # # 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'}" + "flutter", "build", "apk", + "--debug", + "--flavor=#{scheme}", + "--target=#{scheme == 'production' ? 'lib/main_prod.dart' : 'lib/main_dev.dart'}", + "--dart-define=SCREENSHOT_MODE=true" + ) + # Va después de flutter: la tarea de gradle recompila la app sin los defines, pero + # escribe en outputs/apk/, no en flutter-apk/, que es de donde sale el APK de abajo. + gradle(project_dir: File.join(root, "android"), task: "assemble#{scheme.capitalize}DebugAndroidTest") + + app_apk = File.join(root, "build", "app", "outputs", "flutter-apk", "app-#{scheme}-debug.apk") + tests_apk = File.join(root, "build", "app", "outputs", "apk", "androidTest", scheme, "debug", "app-#{scheme}-debug-androidTest.apk") + [app_apk, tests_apk].each do |apk| + UI.user_error!("❌ No se generó #{apk}.") unless File.exist?(apk) + end + + # Los nombres salen de lo que anotó AGP al compilar y no de APP_PACKAGE_NAME + el + # sufijo del flavor: si no calzan, screengrab instala los dos APK igual y recién + # después se cae con "package not found" al dar los permisos y al instrumentar. + # El secreto queda de respaldo, que es lo que hay cuando no está el metadata. + package_name = android_application_id(File.join(root, "build", "app", "outputs", "apk", scheme, "debug", "output-metadata.json")) || package_name + tests_package_name = android_application_id(File.join(root, "build", "app", "outputs", "apk", "androidTest", scheme, "debug", "output-metadata.json")) || "#{package_name}.test" + UI.message("📦 Instalando #{package_name} y #{tests_package_name}") + + # Una instalación previa firmada con otra llave deja a screengrab sin poder instalar + # los APK, con un error que no dice que el problema es ese. + [package_name, tests_package_name].each { |pkg| `#{adb_bin} -s #{serial} uninstall #{pkg}` } + + # El resto de las opciones (idiomas, paquete de los tests, nombres sin marca de + # tiempo) están en fastlane/Screengrabfile. + capture_android_screenshots( + app_package_name: package_name, + tests_package_name: tests_package_name, + app_apk_path: app_apk, + tests_apk_path: tests_apk, + specific_device: serial, + locales: [locale], + output_directory: screengrab_dir, ) ensure # El emulador puede ser uno que ya tenía abierto quien ejecuta el lane. @@ -168,9 +224,13 @@ platform :android do `#{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. + Dir.glob(File.join(screengrab_dir, "**", "*.png")).each { |captura| FileUtils.mv(captura, output_dir) } + FileUtils.rm_rf(screengrab_dir) + + # Un test que se cuelga y agota el tiempo igual puede dejar el lane en verde 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.user_error!("❌ No se generó ninguna captura. Revisa el log de screengrab.") if capturas.empty? if frame # Los fondos se cortan al tamaño exacto de las capturas: si cambia la resolución del diff --git a/docs/GUIA-DE-USO.md b/docs/GUIA-DE-USO.md new file mode 100644 index 0000000..526bf4c --- /dev/null +++ b/docs/GUIA-DE-USO.md @@ -0,0 +1,330 @@ +# Guía de uso de Mi UTEM + +Recorrido completo de la app **Mi UTEM 4.0.0 (build 153)**, verificado en el simulador de iOS (iPhone 17, iOS 26). + +> **Sobre las capturas**: se tomaron ejecutando la app en *modo capturas* +> (`--dart-define=SCREENSHOT_MODE=true`), que reemplaza los servicios de red por los datos +> ficticios de [`lib/core/mock/mock_data.dart`](../lib/core/mock/mock_data.dart). El estudiante +> "Ernesto Carreño Silva", su RUT, sus ramos y sus notas **no corresponden a ninguna persona ni +> matrícula real**. Las pantallas, la navegación y los estados de carga son los mismos que ve +> cualquier estudiante con su cuenta real. + +--- + +## 1. Cómo acceder a la app + +### 1.1 Qué necesitas + +- Un dispositivo iOS o Android con la app Mi UTEM instalada. +- Tus credenciales de **Pasaporte.UTEM** (las mismas del correo institucional y de + [mi.utem.cl](https://mi.utem.cl/)). +- Conexión a internet en el primer inicio de sesión. + +### 1.2 Iniciar sesión + +Al abrir la app por primera vez aparece la pantalla de acceso: el logo de Mi UTEM sobre un video de +fondo del campus, y el formulario de ingreso. + +![Pantalla de inicio de sesión](capturas/01-login.png) + +1. **Usuario/Correo**: escribe solo tu nombre de usuario (por ejemplo `ecarrenos`). La app agrega + automáticamente el sufijo `@utem.cl` que se muestra al costado derecho del campo, así que no + hace falta escribir el correo completo. +2. **Contraseña**: tu contraseña de Pasaporte.UTEM. Se oculta con puntos mientras la escribes. +3. Pulsa **Ingresar**. + +![Formulario completo](capturas/02-login-completo.png) + +Notas útiles: + +- El formulario está integrado con el **llavero de iOS / autocompletado**, por lo que el sistema + puede ofrecerte guardar y rellenar la contraseña. +- **¿Olvidaste tu contraseña?** abre [pasaporte.utem.cl/reset](https://pasaporte.utem.cl/reset) en + el navegador para recuperarla. La app no cambia contraseñas. +- Si las credenciales son incorrectas aparece el mensaje *"Credenciales incorrectas. Por favor + intenta nuevamente."*; si el servicio no responde, *"Ocurrió un error al autenticar."* +- La sesión queda guardada de forma cifrada en el dispositivo: en los siguientes arranques entras + directo al inicio sin volver a escribir la contraseña. + +--- + +## 2. La pantalla de Inicio + +Tras ingresar llegas al Inicio, que resume tu día. + +![Pantalla de inicio](capturas/03-inicio.png) + +De arriba hacia abajo: + +| Bloque | Qué muestra | +| --- | --- | +| **Saludo** | "¡Buenos Días/Tardes/Noches!" según la hora, con tu primer nombre. | +| **¿Qué quieres hacer hoy?** | Accesos rápidos a **Horario**, **Notas** y **Novedades**. | +| **Novedades** | Anuncios del equipo de desarrollo y de la universidad. | +| **Clases de Hoy** | Los bloques del día con hora de inicio/término, nombre del ramo y sala. El bloque en curso se destaca en verde. | + +**Gesto disponible**: desliza hacia abajo (*pull to refresh*) para recargar horario, novedades y +datos de sesión desde el servidor. + +### Barra de navegación inferior + +Las pestañas visibles dependen de tu perfil (estudiante, profesor) y de los *feature flags* +remotos, por lo que pueden variar entre versiones. En esta sesión se mostraron: + +**Inicio · Asignaturas · Credencial · Perfil** + +La app también incluye una pestaña **Apuntes** (tareas y recordatorios personales), que estaba +desactivada por configuración remota durante esta revisión. + +--- + +## 3. Asignaturas + +![Asignaturas](capturas/04-asignaturas.png) + +Contiene dos bloques: + +- **Accesos rápidos**: Horario, Notas y Malla Histórica. +- **En Curso / Asignaturas Inscritas**: cada tarjeta muestra el tipo de hora (Cátedra, Taller, + Laboratorio), el nombre del ramo y su código. Al tocar una asignatura se abre su detalle de notas. + +También admite *pull to refresh* para recargar la lista desde el sistema académico. + +--- + +## 4. Horario + +Se abre desde el acceso rápido **Horario** (en Inicio o en Asignaturas). + +![Horario semanal](capturas/05-horario.png) + +- Vista semanal en cuadrícula: las columnas son los días y las filas los bloques horarios. +- Cada bloque indica **código/sección**, **nombre de la asignatura** y **sala**. +- Una **línea roja con la hora actual** cruza la cuadrícula para ubicarte en el día. +- Se desplaza horizontalmente para ver el resto de la semana y verticalmente para el resto del día. + +El menú **⋮** de la barra superior ofrece tres acciones: + +![Menú del horario](capturas/06-horario-menu.png) + +| Opción | Qué hace | +| --- | --- | +| **Recargar** | Fuerza una actualización del horario desde el servidor, ignorando la caché. | +| **Compartir** | Genera una imagen del horario y abre el menú de compartir de iOS/Android. | +| **Centrar en hora actual** | Desplaza la vista hasta el bloque que está corriendo. | + +--- + +## 5. Calculadora de notas + +Se abre desde el acceso rápido **Notas**. + +![Calculadora de notas vacía](capturas/07-notas.png) + +La pantalla tiene tres secciones: + +1. **Asignatura**: un selector con la opción libre *"Calcular Notas"* y, debajo, tus ramos inscritos. + + ![Selector de asignatura](capturas/08-notas-selector.png) + +2. **Promedio**: el promedio calculado en grande, más las notas de **Presentación** y **Examen**. +3. **Notas**: pares de *nota* + *porcentaje*. Se agregan filas con **+ Agregar Nota** y se eliminan + con el ícono de papelera. + +Al elegir una asignatura se cargan sus evaluaciones reales y el promedio se recalcula al instante +mientras editas. + +![Notas de una asignatura](capturas/09-notas-asignatura.png) + +> **Importante**: como advierte la propia pantalla, *la calculadora sólo sirve para simular el +> cálculo del ramo seleccionado; no modifica ninguna nota ingresada al sistema*. + +--- + +## 6. Malla Histórica + +Acceso rápido dentro de Asignaturas. Muestra el avance curricular por semestre con el estado de +cada asignatura, y permite compartirla como imagen. + +Durante esta revisión la función estaba **desactivada de forma remota** por el equipo: + +![Malla histórica desactivada](capturas/10-malla.png) + +Que una tarjeta se vea atenuada significa que su *feature flag* está apagado. Es un estado temporal +controlado desde el servidor, no un error de tu cuenta ni del teléfono. + +--- + +## 7. Credencial + +Tu credencial universitaria digital. + +![Credencial - anverso](capturas/11-credencial.png) + +El anverso muestra: + +- Tu fotografía institucional. +- Tu **perfil** (Estudiante / Profesor) como distintivo de color. +- Nombre y apellidos. +- Un **código QR** para validación. +- Tu RUT con formato. + +Tocando la tarjeta se voltea y muestra el reverso institucional: + +![Credencial - reverso](capturas/12-credencial-reverso.png) + +*Pull to refresh* vuelve a pedir tus datos al servidor si tu foto o perfil cambiaron. + +--- + +## 8. Novedades + +Se abre desde el acceso rápido **Novedades** del Inicio. + +![Novedades](capturas/17-novedades.png) + +Reúne dos cosas: + +- **Anuncios de la app**, publicados por el equipo de desarrollo mediante configuración remota. +- **Noticias** de la UTEM, en tarjetas horizontales con imagen y titular. Al tocarlas se abre la + noticia completa en el navegador. + +--- + +## 9. Perfil y ajustes + +![Perfil](capturas/13-perfil.png) + +Arriba se muestran tu foto, nombre completo, correo institucional y carrera; debajo, el botón +**Cerrar Sesión**, que borra las credenciales guardadas y te devuelve al login. + +Al desplazarte aparecen las secciones de configuración: + +![Ajustes del perfil](capturas/14-perfil-ajustes.png) + +| Sección | Opción | Descripción | +| --- | --- | --- | +| **Sistema** | Habilitar Notificaciones | Activa o desactiva las notificaciones de la app. | +| **Pantalla** | Tema de la aplicación | Claro, Oscuro o seguir el tema del Sistema. | +| **Feedback** | Reportar un Bug | Abre el formulario para reportar fallas. | +| **Feedback** | Sugerencias | Envía comentarios y propuestas al equipo. | +| **Aplicación** | Desarrolladores de la App | Abre la lista de colaboradores en GitHub. | +| **Aplicación** | Versión de la Aplicación | Versión y número de compilación instalados. | + +### Cambiar el tema + +**Perfil → Pantalla → Tema de la aplicación** abre el selector: + +![Selector de tema](capturas/15-tema.png) + +Con **Oscuro**, toda la app adopta la paleta oscura: + +![Inicio en modo oscuro](capturas/16-inicio-oscuro.png) + +### Modo de depuración (uso interno) + +En las compilaciones de desarrollo, tocando **cinco veces** la fila *"Versión de la Aplicación"* se +habilita una sección **Depuración** con el sabor de la app, el ID de usuario, reinicio de sesión y +un generador de errores de prueba. En producción esta opción no está disponible. + +--- + +## 10. Apuntes + +La pestaña **Apuntes** (cuando su *feature flag* está activo) es una lista personal de tareas y +recordatorios, guardada localmente en el dispositivo: + +- Crear tareas con **título**, **contenido**, **estado**, **categoría** (puede ser una de tus + asignaturas) y un **color** identificador. +- Marcar, editar y eliminar tareas. +- Recibir recordatorios mediante notificaciones locales. + +No estuvo visible durante esta revisión porque la pestaña venía desactivada desde la configuración +remota. + +--- + +## Anexo — Ejecutar la app en el simulador de iOS + +Referencia para el equipo de desarrollo; reproduce exactamente lo hecho para esta guía. + +### Requisitos + +- Flutter con el SDK de iOS configurado y Xcode instalado. +- **CocoaPods funcionando** (`pod --version` debe responder). Si está roto: + ```bash + gem install cocoapods --user-install + ``` + y agrega `~/.gem/ruby//bin` al `PATH`. +- Dependencias instaladas: + ```bash + flutter pub get + ``` + +### Levantar la app con datos de demostración + +```bash +flutter run --flavor development -t lib/main_dev.dart --dart-define=SCREENSHOT_MODE=true +``` + +Con `SCREENSHOT_MODE=true` la app usa `registrarServiciosMock()`: acepta cualquier usuario y +contraseña en el login y devuelve siempre el estudiante ficticio. La sesión vive en memoria, por lo +que cada ejecución parte desde el login. + +Para usar el sistema académico real, omite el `--dart-define`. + +### Capturas automatizadas para las tiendas + +El repositorio incluye un recorrido automatizado que genera las capturas de App Store, Mac App Store +y Play Store. Cada plataforma usa la herramienta de fastlane que le corresponde, y las tres dejan +las capturas enmarcadas con `frameit` y los fondos de `scripts/generate-screenshot-backgrounds`: + +| Plataforma | Herramienta | Recorrido | Lane | +| --- | --- | --- | --- | +| iPhone y iPad | [snapshot](https://docs.fastlane.tools/getting-started/ios/screenshots/) (UI Tests de Xcode) | [`ios/RunnerUITests/ScreenshotsUITests.swift`](../ios/RunnerUITests/ScreenshotsUITests.swift) | `bundle exec fastlane ios screenshots` | +| Android | [screengrab](https://docs.fastlane.tools/getting-started/android/screenshots/) (tests instrumentados) | [`android/app/src/androidTest/kotlin/cl/inndev/miutem/ScreenshotsTest.kt`](../android/app/src/androidTest/kotlin/cl/inndev/miutem/ScreenshotsTest.kt) | `bundle exec fastlane android screenshots` | +| macOS | `flutter drive` | [`integration_test/screenshots_test.dart`](../integration_test/screenshots_test.dart) | `bundle exec fastlane mac screenshots` | + +macOS va aparte porque snapshot recorre la app con un simulador de iOS y para el escritorio no hay +equivalente. + +Los tres recorridos compilan la app con `--dart-define=SCREENSHOT_MODE=true`, así que trabajan con +los datos ficticios y no necesitan ninguna cuenta real. La configuración de snapshot está en +[`fastlane/Snapfile`](../fastlane/Snapfile) y la de screengrab en +[`fastlane/Screengrabfile`](../fastlane/Screengrabfile). + +En CI los ejecuta el workflow [`screenshots.yml`](../.github/workflows/screenshots.yml), que corre +al hacer push a `dev` y `prod`, al abrir una pull request, al sacarla de borrador, a mano desde la +pestaña Actions y comentando en una pull request: + +``` +@miutem screenshot # las tres plataformas +@miutem screenshot ios # sólo iPhone y iPad +@miutem screenshot android +@miutem screenshot macos +@miutem screenshot all +``` + +El comando sólo lo atiende a quien tenga permiso de escritura en el repositorio, porque la corrida +usa los secretos del repositorio sobre el código de la rama de la pull request. Cuando se toma, el +comentario queda con una reacción 👀: el run no aparece entre los checks de la pull request, así que +esa es la señal de que arrancó. + +En las pull requests el workflow deja además un comentario con lo que salió en cada plataforma y el +enlace para descargar las capturas, que arma +[`scripts/pr-screenshots-comment`](../scripts/pr-screenshots-comment): las imágenes no se pueden +mostrar embebidas porque GitHub no deja adjuntar archivos a un comentario desde su API. + +### Problemas conocidos al compilar + +- **`Command PhaseScriptExecution failed`** en la fase *FlutterFire: upload-crashlytics-symbols*: + el script busca el binario `Crashlytics/run` bajo `DerivedData/Runner-*/SourcePackages/`, mientras + que los paquetes se resuelven en `build/ios/SourcePackages/`. Se soluciona enlazando esa ruta: + ```bash + ln -sfn "$PWD/build/ios/SourcePackages" ~/Library/Developer/Xcode/DerivedData/Runner-*/SourcePackages + ``` +- **`CocoaPods not installed or not in valid state`**: `pod` está roto o solo accesible vía + `bundle exec`. Instálalo como gem de usuario (ver más arriba) en vez de envolverlo con bundler: + Flutter invoca `pod` directamente y con un envoltorio lento el arranque se degrada mucho. +- La primera compilación descarga y compila todo el SDK de Firebase para iOS: puede tomar entre 15 y + 40 minutos. Las siguientes bajan a ~1 minuto. diff --git a/docs/capturas/01-login.png b/docs/capturas/01-login.png new file mode 100644 index 0000000..32289ac Binary files /dev/null and b/docs/capturas/01-login.png differ diff --git a/docs/capturas/02-login-completo.png b/docs/capturas/02-login-completo.png new file mode 100644 index 0000000..a68b090 Binary files /dev/null and b/docs/capturas/02-login-completo.png differ diff --git a/docs/capturas/03-inicio.png b/docs/capturas/03-inicio.png new file mode 100644 index 0000000..a2fa403 Binary files /dev/null and b/docs/capturas/03-inicio.png differ diff --git a/docs/capturas/04-asignaturas.png b/docs/capturas/04-asignaturas.png new file mode 100644 index 0000000..32c4697 Binary files /dev/null and b/docs/capturas/04-asignaturas.png differ diff --git a/docs/capturas/05-horario.png b/docs/capturas/05-horario.png new file mode 100644 index 0000000..90e1ac4 Binary files /dev/null and b/docs/capturas/05-horario.png differ diff --git a/docs/capturas/06-horario-menu.png b/docs/capturas/06-horario-menu.png new file mode 100644 index 0000000..2d99d5e Binary files /dev/null and b/docs/capturas/06-horario-menu.png differ diff --git a/docs/capturas/07-notas.png b/docs/capturas/07-notas.png new file mode 100644 index 0000000..7625a66 Binary files /dev/null and b/docs/capturas/07-notas.png differ diff --git a/docs/capturas/08-notas-selector.png b/docs/capturas/08-notas-selector.png new file mode 100644 index 0000000..9a4fb15 Binary files /dev/null and b/docs/capturas/08-notas-selector.png differ diff --git a/docs/capturas/09-notas-asignatura.png b/docs/capturas/09-notas-asignatura.png new file mode 100644 index 0000000..239b116 Binary files /dev/null and b/docs/capturas/09-notas-asignatura.png differ diff --git a/docs/capturas/10-malla.png b/docs/capturas/10-malla.png new file mode 100644 index 0000000..a885174 Binary files /dev/null and b/docs/capturas/10-malla.png differ diff --git a/docs/capturas/11-credencial.png b/docs/capturas/11-credencial.png new file mode 100644 index 0000000..0171177 Binary files /dev/null and b/docs/capturas/11-credencial.png differ diff --git a/docs/capturas/12-credencial-reverso.png b/docs/capturas/12-credencial-reverso.png new file mode 100644 index 0000000..09c0078 Binary files /dev/null and b/docs/capturas/12-credencial-reverso.png differ diff --git a/docs/capturas/13-perfil.png b/docs/capturas/13-perfil.png new file mode 100644 index 0000000..9e075fc Binary files /dev/null and b/docs/capturas/13-perfil.png differ diff --git a/docs/capturas/14-perfil-ajustes.png b/docs/capturas/14-perfil-ajustes.png new file mode 100644 index 0000000..35c00b6 Binary files /dev/null and b/docs/capturas/14-perfil-ajustes.png differ diff --git a/docs/capturas/15-tema.png b/docs/capturas/15-tema.png new file mode 100644 index 0000000..bbfc3ed Binary files /dev/null and b/docs/capturas/15-tema.png differ diff --git a/docs/capturas/16-inicio-oscuro.png b/docs/capturas/16-inicio-oscuro.png new file mode 100644 index 0000000..7fbb557 Binary files /dev/null and b/docs/capturas/16-inicio-oscuro.png differ diff --git a/docs/capturas/17-novedades.png b/docs/capturas/17-novedades.png new file mode 100644 index 0000000..1d1b357 Binary files /dev/null and b/docs/capturas/17-novedades.png differ diff --git a/fastlane/.env.example b/fastlane/.env.example index 8db08dc..b3c10fb 100644 --- a/fastlane/.env.example +++ b/fastlane/.env.example @@ -34,8 +34,9 @@ MATCH_GIT_BASIC_AUTHORIZATION= 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" +# Simuladores a usar, separados por coma (basta el prefijo del nombre). La App Store pide +# el tamaño de 6.9" (iPhone 17/16 Pro Max) y uno de iPad de 13". +SCREENSHOT_DEVICES="iPhone 17 Pro Max,iPad Air 13-inch" # 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/ @@ -50,6 +51,11 @@ 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= +# Capturas de pantalla automatizadas (lane `mac screenshots`) +# Tamaño de la ventana en puntos. El recorrido rasteriza al doble, así que 1440x900 da +# capturas de 2880x1800, una de las formas que acepta la Mac App Store. +SCREENSHOT_MACOS_SIZE=1440x900 + # 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 6080c65..39a85d6 100644 --- a/fastlane/.gitignore +++ b/fastlane/.gitignore @@ -1,4 +1,6 @@ build/ # Las capturas se generan con `fastlane ios screenshots`; la configuración de frameit sí se versiona. screenshots/**/*.png +# Los marcos de dispositivo no se generan: están bajados de fastlane.github.io/frameit-frames. +!screenshots/frames/*.png *.xml diff --git a/fastlane/README.md b/fastlane/README.md index 34a9b2e..6cc1cee 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -69,7 +69,7 @@ Construye y firma la app para distribución en iOS [bundle exec] fastlane ios screenshots ``` -Genera las capturas de pantalla de iPhone en el simulador y las guarda en fastlane/screenshots +Genera las capturas de pantalla de iPhone y iPad en el simulador y las guarda en fastlane/screenshots ### ios upload_screenshots @@ -209,6 +209,14 @@ Compila la app con flutter para macOS Construye y firma la app para distribución en macOS +### mac screenshots + +```sh +[bundle exec] fastlane mac screenshots +``` + +Genera las capturas de pantalla de macOS y las guarda en macos/fastlane/screenshots + ### mac load_api_key ```sh diff --git a/fastlane/Screengrabfile b/fastlane/Screengrabfile new file mode 100644 index 0000000..1090c88 --- /dev/null +++ b/fastlane/Screengrabfile @@ -0,0 +1,25 @@ +# Configuración de screengrab, el generador de capturas de fastlane para Android. +# https://docs.fastlane.tools/getting-started/android/screenshots/ +# +# Acá vive lo que no cambia entre corridas. El paquete de la app, las rutas de los APK, el +# dispositivo y la carpeta de salida los pasa el lane `android screenshots`, porque +# dependen del flavor y del emulador que haya levantado. + +# El idioma de la ficha de Google Play, y el nombre de la carpeta en la que quedan las +# capturas. +locales(["es-419"]) + +# El recorrido vive en android/app/src/androidTest/kotlin/cl/inndev/miutem: acotarlo evita +# que un test que se agregue después termine metido en las capturas de la tienda. +use_tests_in_packages(["cl.inndev.miutem"]) + +# Google Play sólo acepta 8 capturas por dispositivo: si quedan las de la corrida anterior, +# el upload falla. +clear_previous_screenshots(true) + +# Sin la marca de tiempo en el nombre: el orden de las capturas en la ficha es el orden +# alfabético de los archivos, y de ahí también sacan su título frameit y el Framefile. +use_timestamp_suffix(false) + +# En CI no hay quién mire el resumen HTML. +skip_open_summary(true) diff --git a/fastlane/Snapfile b/fastlane/Snapfile new file mode 100644 index 0000000..875a975 --- /dev/null +++ b/fastlane/Snapfile @@ -0,0 +1,28 @@ +# Configuración de snapshot, el generador de capturas de fastlane para iOS. +# https://docs.fastlane.tools/actions/snapshot/ +# +# Acá vive lo que no cambia entre corridas, para que `fastlane snapshot` a secas también +# funcione. El lane `ios screenshots` pisa el dispositivo, la carpeta de salida y la ruta +# del workspace —dependen del runner y del simulador disponible— y también el scheme y la +# configuración cuando se le pide el flavor de desarrollo. + +# El scheme que corre el target de UI Tests (RunnerUITests). Es el del flavor de +# producción: el nombre, el ícono y el bundle id de la tienda salen de ahí. +scheme("production") + +# La ficha de la App Store está en español y el resto del pipeline —los títulos de +# fastlane/screenshots/es-MX y el Framefile de frameit— trabaja sobre esta carpeta. +languages(["es-MX"]) + +# Las capturas salen de una compilación de debug del flavor de producción, que es la que +# deja `flutter build ios --config-only --debug --flavor production`. +configuration("Debug-production") + +# En el simulador no se firma nada, pero el proyecto declara el perfil de aprovisionamiento +# del dispositivo real: sin esto xcodebuild lo exigiría y en CI no existe. +xcargs("CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY='' PROVISIONING_PROFILE_SPECIFIER=''") + +# En CI no hay quién mire el resumen HTML, y un dispositivo que falle tiene que cortar el +# job: una ficha a medias no sirve. +skip_open_summary(true) +stop_after_first_error(true) diff --git a/fastlane/screenshots/frames/Apple MacBook Pro 16 Space Gray.png b/fastlane/screenshots/frames/Apple MacBook Pro 16 Space Gray.png new file mode 100644 index 0000000..77c59c1 Binary files /dev/null and b/fastlane/screenshots/frames/Apple MacBook Pro 16 Space Gray.png differ diff --git a/integration_test/screenshots_test.dart b/integration_test/screenshots_test.dart index 357c3b3..5c12755 100644 --- a/integration_test/screenshots_test.dart +++ b/integration_test/screenshots_test.dart @@ -1,7 +1,9 @@ import "dart:io"; +import "dart:ui" as ui; import "package:adaptive_theme/adaptive_theme.dart"; import "package:flutter/material.dart"; +import "package:flutter/rendering.dart"; import "package:flutter_test/flutter_test.dart"; import "package:integration_test/integration_test.dart"; import "package:miutem/firebase_options_dev.dart" as dev; @@ -176,6 +178,11 @@ Future capturar(WidgetTester tester, IntegrationTestWidgetsFlutterBinding FocusManager.instance.primaryFocus?.unfocus(); await esperar(tester, const Duration(seconds: 2)); + if (Platform.isMacOS) { + await capturarDesdeFlutter(tester, binding, archivo); + return; + } + // 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. @@ -187,3 +194,44 @@ Future capturar(WidgetTester tester, IntegrationTestWidgetsFlutterBinding await captura; paso(binding, "capturada $archivo"); } + +/// Ancho en píxeles de las capturas de macOS. La Mac App Store sólo acepta 1280x800, +/// 1440x900, 2560x1600 y 2880x1800: la ventana se abre en 1440x900 (MIUTEM_WINDOW_SIZE) +/// y acá se rasteriza al doble, así el resultado calza con 2880x1800 sin recortar nada. +const double anchoCapturaMacOS = 2880; + +/// Captura la ventana desde el árbol de Flutter y la deja en `reportData`, que es de donde +/// el driver escribe los archivos (misma llave que usa `binding.takeScreenshot`). +/// +/// En macOS el plugin de integration_test sólo responde `allTestsFinished`: no implementa +/// `captureScreenshot`, así que la captura nativa devuelve MissingPluginException. Rasterizar +/// la capa raíz da lo mismo en pantalla y además deja elegir la resolución de salida. +Future capturarDesdeFlutter(WidgetTester tester, IntegrationTestWidgetsFlutterBinding binding, String archivo) async { + final vista = tester.binding.renderViews.first; + final capa = vista.debugLayer! as OffsetLayer; + + ui.Image? imagen; + var listo = false; + // Igual que en iOS: el binding sólo dibuja cuando se le pide, así que se sigue bombeando + // mientras el rasterizado está en curso. Se espera por `listo` y no por la imagen, para + // que un error corte el bucle y salga por el `await` de abajo. + final render = capa + .toImage(vista.paintBounds, pixelRatio: anchoCapturaMacOS / vista.paintBounds.width) + .then((resultado) => imagen = resultado) + .whenComplete(() => listo = true); + while (!listo) { + await tester.pump(const Duration(milliseconds: 50)); + } + await render; + + final bytes = await imagen!.toByteData(format: ui.ImageByteFormat.png); + imagen!.dispose(); + + binding.reportData ??= {}; + final capturas = binding.reportData!["screenshots"] ??= []; + (capturas as List).add({ + "screenshotName": archivo, + "bytes": bytes!.buffer.asUint8List(), + }); + paso(binding, "capturada $archivo"); +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1fea4c8..50963f1 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ A1D229722F8C93E500B67576 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = A1D229712F8C93E500B67576 /* PrivacyInfo.xcprivacy */; }; A1E1C1322F70DA1500B90DEF /* LaunchScreenProduction.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A1E1C12F2F70DA1500B90DEF /* LaunchScreenProduction.storyboard */; }; A1E1C1332F70DA1500B90DEF /* LaunchScreenDevelopment.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A1E1C12E2F70DA1500B90DEF /* LaunchScreenDevelopment.storyboard */; }; + F174AE150A877AED931BA98E /* SnapshotHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E692E61BC0FA5BF77889EB90 /* SnapshotHelper.swift */; }; + FB6FB775BB7C12B752D747B3 /* ScreenshotsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3450DA68FD8D61E8B3E00A /* ScreenshotsUITests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -32,6 +34,13 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + 8918B36F4F4A5CEEE09A3CEC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -50,6 +59,7 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1E3450DA68FD8D61E8B3E00A /* ScreenshotsUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ScreenshotsUITests.swift; sourceTree = ""; }; 1ED739468E9E17CBDDB25F6D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 233F7946EA6EE7A2B36F9F8F /* Pods-Runner.release-development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release-development.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release-development.xcconfig"; sourceTree = ""; }; 2553D2A7855C5FC6079F6A90 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; @@ -91,7 +101,9 @@ AE389D98D4B7D93AAD40588E /* Pods-Runner.profile-development.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile-development.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile-development.xcconfig"; sourceTree = ""; }; B68E884D89B97CF95ADEFFBF /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; C1A42E756791F7C5F3564B68 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + E692E61BC0FA5BF77889EB90 /* SnapshotHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnapshotHelper.swift; sourceTree = ""; }; F6170E9F8A95A6AA31F60344 /* Pods-RunnerTests.debug-production.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug-production.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug-production.xcconfig"; sourceTree = ""; }; + F8686D6CB2451A9295674976 /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -112,6 +124,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D973F1F09C8B0FF1846805E5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -123,6 +142,13 @@ path = RunnerTests; sourceTree = ""; }; + 866FE3FEED8E4ACD0CF964BC /* iOS */ = { + isa = PBXGroup; + children = ( + ); + name = iOS; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -146,6 +172,7 @@ CDA5E1A1201007414584C1C1 /* Pods */, A1D05B202F5A682900978293 /* Firebase */, CAF3FD667F04346075ABD1F7 /* Frameworks */, + E087382B8617BC168DD73F3C /* RunnerUITests */, ); sourceTree = ""; }; @@ -154,6 +181,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + F8686D6CB2451A9295674976 /* RunnerUITests.xctest */, ); name = Products; sourceTree = ""; @@ -206,6 +234,7 @@ children = ( 1ED739468E9E17CBDDB25F6D /* Pods_Runner.framework */, 8F8F202B4743B9B36BF2BADE /* Pods_RunnerTests.framework */, + 866FE3FEED8E4ACD0CF964BC /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -235,6 +264,16 @@ path = Pods; sourceTree = ""; }; + E087382B8617BC168DD73F3C /* RunnerUITests */ = { + isa = PBXGroup; + children = ( + 1E3450DA68FD8D61E8B3E00A /* ScreenshotsUITests.swift */, + E692E61BC0FA5BF77889EB90 /* SnapshotHelper.swift */, + ); + name = RunnerUITests; + path = RunnerUITests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -284,6 +323,24 @@ productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + 996BEB5153C1A8FBEFB337CD /* RunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 15C6DC67DC69FC1846CD4155 /* Build configuration list for PBXNativeTarget "RunnerUITests" */; + buildPhases = ( + 790B0480F39EF6DB8360C71D /* Sources */, + D973F1F09C8B0FF1846805E5 /* Frameworks */, + 5FC7234E9FDDEA68FB2C8BA5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 1D9833339CA1E24DAB8E32D0 /* PBXTargetDependency */, + ); + name = RunnerUITests; + productName = RunnerUITests; + productReference = F8686D6CB2451A9295674976 /* RunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -322,6 +379,7 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + 996BEB5153C1A8FBEFB337CD /* RunnerUITests */, ); }; /* End PBXProject section */ @@ -335,6 +393,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5FC7234E9FDDEA68FB2C8BA5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -492,6 +557,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 790B0480F39EF6DB8360C71D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FB6FB775BB7C12B752D747B3 /* ScreenshotsUITests.swift in Sources */, + F174AE150A877AED931BA98E /* SnapshotHelper.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -504,6 +578,12 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 1D9833339CA1E24DAB8E32D0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = Runner; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 8918B36F4F4A5CEEE09A3CEC /* PBXContainerItemProxy */; + }; 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 97C146ED1CF9000F007C117D /* Runner */; @@ -549,6 +629,34 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 1C7675C85972D0CE8B071F10 /* Release-development */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = cl.utem.miutem.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = "Release-development"; + }; 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = A1BDE47A2F4389DF007F51CD /* Profile.xcconfig */; @@ -697,6 +805,117 @@ }; name = Profile; }; + 4FE84AC34C272CC5C28222DC /* Profile-production */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = cl.utem.miutem.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = "Profile-production"; + }; + 5C9CCF6AC5CDA233A95B0CA3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = cl.utem.miutem.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 60122AF4F173E7C9FDF0BEB9 /* Profile-development */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = cl.utem.miutem.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = "Profile-development"; + }; + 8C10EC5D9570A92C415570FE /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = cl.utem.miutem.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + }; + name = Debug; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1549,9 +1768,135 @@ }; name = "Profile-production"; }; + BCD9B621240E61DC4CA3ECDC /* Debug-development */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = cl.utem.miutem.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + }; + name = "Debug-development"; + }; + BD1ECC0A37746780FF02D9B4 /* Debug-production */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = cl.utem.miutem.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + }; + name = "Debug-production"; + }; + DC5EB922314A5246A93C1576 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = cl.utem.miutem.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + F0DB0CDC308283843C478717 /* Release-production */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGNING_ALLOWED = NO; + CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ""; + PRODUCT_BUNDLE_IDENTIFIER = cl.utem.miutem.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Runner; + VALIDATE_PRODUCT = YES; + }; + name = "Release-production"; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 15C6DC67DC69FC1846CD4155 /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5C9CCF6AC5CDA233A95B0CA3 /* Release */, + 8C10EC5D9570A92C415570FE /* Debug */, + BD1ECC0A37746780FF02D9B4 /* Debug-production */, + BCD9B621240E61DC4CA3ECDC /* Debug-development */, + F0DB0CDC308283843C478717 /* Release-production */, + 1C7675C85972D0CE8B071F10 /* Release-development */, + DC5EB922314A5246A93C1576 /* Profile */, + 4FE84AC34C272CC5C28222DC /* Profile-production */, + 60122AF4F173E7C9FDF0BEB9 /* Profile-development */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/development.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/development.xcscheme index 0b77708..116b2ac 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/development.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/development.xcscheme @@ -39,6 +39,20 @@ ReferencedContainer = "container:Runner.xcodeproj"> + + + + + + + + + + + + + + + + + + + + NSPredicate { + NSPredicate(format: "label CONTAINS %@ OR identifier == %@", etiqueta, etiqueta) + } + + /// Espera a que aparezca un nodo con esa etiqueta y devuelve el que se puede tocar: el + /// mismo texto suele estar además como texto suelto dentro del control. + @discardableResult + @MainActor + private func esperar(_ app: XCUIApplication, _ etiqueta: String, _ descripcion: String) -> XCUIElement { + let cualquiera = app.descendants(matching: .any).matching(predicado(etiqueta)).firstMatch + XCTAssertTrue( + cualquiera.waitForExistence(timeout: limiteDeEspera), + "No apareció \(descripcion) después de \(Int(limiteDeEspera))s." + ) + + let boton = app.buttons.matching(predicado(etiqueta)).firstMatch + return boton.exists ? boton : cualquiera + } + + @discardableResult + @MainActor + private func esperar(_ elemento: XCUIElement, _ descripcion: String) -> XCUIElement { + XCTAssertTrue( + elemento.waitForExistence(timeout: limiteDeEspera), + "No apareció \(descripcion) después de \(Int(limiteDeEspera))s." + ) + return elemento + } + + /// Toca un elemento, desplazando la pantalla si quedó fuera de vista: Perfil publica + /// todos sus ajustes en el árbol, pero XCUITest sólo puede tocar los que se ven. + @MainActor + private func tocar(_ app: XCUIApplication, _ etiqueta: String, _ descripcion: String) { + let elemento = esperar(app, etiqueta, descripcion) + + var intentos = 0 + while !elemento.isHittable && intentos < 6 { + app.swipeUp() + intentos += 1 + } + + XCTAssertTrue(elemento.isHittable, "\(descripcion) nunca quedó a la vista para tocarlo.") + elemento.tap() + } + + /// Las pantallas cargan sus datos al abrirse —aunque sean los ficticios— y el cambio de + /// pestaña se anima, así que se les da tiempo antes de capturar. + private func esperarCarga(_ segundos: UInt32 = 8) { + sleep(segundos) + } +} diff --git a/ios/RunnerUITests/SnapshotHelper.swift b/ios/RunnerUITests/SnapshotHelper.swift new file mode 100644 index 0000000..6dec130 --- /dev/null +++ b/ios/RunnerUITests/SnapshotHelper.swift @@ -0,0 +1,313 @@ +// +// SnapshotHelper.swift +// Example +// +// Created by Felix Krause on 10/8/15. +// + +// ----------------------------------------------------- +// IMPORTANT: When modifying this file, make sure to +// increment the version number at the very +// bottom of the file to notify users about +// the new SnapshotHelper.swift +// ----------------------------------------------------- + +import Foundation +import XCTest + +@MainActor +func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { + Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations) +} + +@MainActor +func snapshot(_ name: String, waitForLoadingIndicator: Bool) { + if waitForLoadingIndicator { + Snapshot.snapshot(name) + } else { + Snapshot.snapshot(name, timeWaitingForIdle: 0) + } +} + +/// - Parameters: +/// - name: The name of the snapshot +/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait. +@MainActor +func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { + Snapshot.snapshot(name, timeWaitingForIdle: timeout) +} + +enum SnapshotError: Error, CustomDebugStringConvertible { + case cannotFindSimulatorHomeDirectory + case cannotRunOnPhysicalDevice + + var debugDescription: String { + switch self { + case .cannotFindSimulatorHomeDirectory: + return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable." + case .cannotRunOnPhysicalDevice: + return "Can't use Snapshot on a physical device." + } + } +} + +@objcMembers +@MainActor +open class Snapshot: NSObject { + static var app: XCUIApplication? + static var waitForAnimations = true + static var cacheDirectory: URL? + static var screenshotsDirectory: URL? { + return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true) + } + static var deviceLanguage = "" + static var currentLocale = "" + + open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) { + + Snapshot.app = app + Snapshot.waitForAnimations = waitForAnimations + + do { + let cacheDir = try getCacheDirectory() + Snapshot.cacheDirectory = cacheDir + setLanguage(app) + setLocale(app) + setLaunchArguments(app) + } catch let error { + NSLog(error.localizedDescription) + } + } + + class func setLanguage(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("language.txt") + + do { + let trimCharacterSet = CharacterSet.whitespacesAndNewlines + deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"] + } catch { + NSLog("Couldn't detect/set language...") + } + } + + class func setLocale(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("locale.txt") + + do { + let trimCharacterSet = CharacterSet.whitespacesAndNewlines + currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet) + } catch { + NSLog("Couldn't detect/set locale...") + } + + if currentLocale.isEmpty && !deviceLanguage.isEmpty { + currentLocale = Locale(identifier: deviceLanguage).identifier + } + + if !currentLocale.isEmpty { + app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""] + } + } + + class func setLaunchArguments(_ app: XCUIApplication) { + guard let cacheDirectory = self.cacheDirectory else { + NSLog("CacheDirectory is not set - probably running on a physical device?") + return + } + + let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt") + app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"] + + do { + let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8) + let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: []) + let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count)) + let results = matches.map { result -> String in + (launchArguments as NSString).substring(with: result.range) + } + app.launchArguments += results + } catch { + NSLog("Couldn't detect/set launch_arguments...") + } + } + + open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) { + if timeout > 0 { + waitForLoadingIndicatorToDisappear(within: timeout) + } + + NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work + + if Snapshot.waitForAnimations { + sleep(1) // Waiting for the animation to be finished (kind of) + } + + #if os(OSX) + guard let app = self.app else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: []) + #else + + guard self.app != nil else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + let screenshot = XCUIScreen.main.screenshot() + #if os(iOS) && !targetEnvironment(macCatalyst) + let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image + #else + let image = screenshot.image + #endif + + guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return } + + do { + // The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices + let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ") + let range = NSRange(location: 0, length: simulator.count) + simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "") + + let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png") + #if swift(<5.0) + try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic) + #else + try image.pngData()?.write(to: path, options: .atomic) + #endif + } catch let error { + NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png") + NSLog(error.localizedDescription) + } + #endif + } + + class func fixLandscapeOrientation(image: UIImage) -> UIImage { + #if os(watchOS) + return image + #else + if #available(iOS 10.0, *) { + let format = UIGraphicsImageRendererFormat() + format.scale = image.scale + let renderer = UIGraphicsImageRenderer(size: image.size, format: format) + return renderer.image { context in + image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height)) + } + } else { + return image + } + #endif + } + + class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) { + #if os(tvOS) + return + #endif + + guard let app = self.app else { + NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + return + } + + let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element + let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator) + _ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout) + } + + class func getCacheDirectory() throws -> URL { + let cachePath = "Library/Caches/tools.fastlane" + // on OSX config is stored in /Users//Library + // and on iOS/tvOS/WatchOS it's in simulator's home dir + #if os(OSX) + let homeDir = URL(fileURLWithPath: NSHomeDirectory()) + return homeDir.appendingPathComponent(cachePath) + #elseif arch(i386) || arch(x86_64) || arch(arm64) + guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else { + throw SnapshotError.cannotFindSimulatorHomeDirectory + } + let homeDir = URL(fileURLWithPath: simulatorHostHome) + return homeDir.appendingPathComponent(cachePath) + #else + throw SnapshotError.cannotRunOnPhysicalDevice + #endif + } +} + +private extension XCUIElementAttributes { + var isNetworkLoadingIndicator: Bool { + if hasAllowListedIdentifier { return false } + + let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20) + let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3) + + return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize + } + + var hasAllowListedIdentifier: Bool { + let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"] + + return allowListedIdentifiers.contains(identifier) + } + + func isStatusBar(_ deviceWidth: CGFloat) -> Bool { + if elementType == .statusBar { return true } + guard frame.origin == .zero else { return false } + + let oldStatusBarSize = CGSize(width: deviceWidth, height: 20) + let newStatusBarSize = CGSize(width: deviceWidth, height: 44) + + return [oldStatusBarSize, newStatusBarSize].contains(frame.size) + } +} + +private extension XCUIElementQuery { + var networkLoadingIndicators: XCUIElementQuery { + let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isNetworkLoadingIndicator + } + + return self.containing(isNetworkLoadingIndicator) + } + + @MainActor + var deviceStatusBars: XCUIElementQuery { + guard let app = Snapshot.app else { + fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().") + } + + let deviceWidth = app.windows.firstMatch.frame.width + + let isStatusBar = NSPredicate { (evaluatedObject, _) in + guard let element = evaluatedObject as? XCUIElementAttributes else { return false } + + return element.isStatusBar(deviceWidth) + } + + return self.containing(isStatusBar) + } +} + +private extension CGFloat { + func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool { + return numberA...numberB ~= self + } +} + +// Please don't remove the lines below +// They are used to detect outdated configuration files +// SnapshotHelperVersion [1.30] diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 63b36cf..11548bf 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -1,16 +1,20 @@ import "../fastlane/src/helpers.rb" require "json" +require "tmpdir" -# Busca el UDID de un simulador por nombre, prefiriendo el runtime de iOS más nuevo. -def ios_simulator_udid(name) +# Busca un simulador por nombre y devuelve [udid, nombre real], prefiriendo el runtime de +# iOS más nuevo. El nombre puede ser un prefijo ("iPad Air 13-inch" toma el M2, M3 o M4 +# según el Xcode del runner, que cambia con cada versión). +def ios_simulator(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 } + disponibles = runtimes.flat_map { |runtime| devices[runtime] } + device = disponibles.find { |d| d["name"] == name } || disponibles.find { |d| d["name"].start_with?(name) } UI.user_error!("❌ No existe el simulador '#{name}'. Créalo en Xcode o ajusta SCREENSHOT_DEVICES.") unless device - device["udid"] + [device["udid"], device["name"]] end platform :ios do @@ -183,15 +187,17 @@ 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" + # El recorrido lo maneja snapshot con los UI Tests de Apple, como indica la guía de + # fastlane (docs.fastlane.tools/getting-started/ios/screenshots): vive en + # ios/RunnerUITests/ScreenshotsUITests.swift, se configura en fastlane/Snapfile y deja + # las capturas en fastlane/screenshots// con el nombre "-.png", + # que es de donde frameit y deliver deducen el dispositivo. + desc "Genera las capturas de pantalla de iPhone y iPad 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"]) + # La App Store pide un juego por tamaño de pantalla: el iPhone más grande y un iPad de + # 13". El iPad Air es el único de ese tamaño que da 2048x2732, la resolución que frameit + # sabe enmarcar (los iPad Pro M4 en adelante dan 2064x2752 y frameit no los conoce). + devices = array_option(options, :devices, env_var: "SCREENSHOT_DEVICES", default: ["iPhone 17 Pro Max", "iPad Air 13-inch"]) 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) @@ -211,43 +217,95 @@ platform :ios do Dir.glob(File.join(output_dir, "*.png")).each { |captura| File.delete(captura) } if clear FileUtils.mkdir_p(output_dir) + # Cada dispositivo se enmarca solo en la carpeta y recién al final se juntan todos: los + # fondos y el marco salen del tamaño de las capturas que estén ahí, así que mezclar + # iPhone con iPad dejaría a uno de los dos con los fondos recortados del otro. + listas = Dir.mktmpdir("miutem-capturas") + + # El paso de símbolos de Crashlytics que agrega flutterfire saca la carpeta de paquetes + # de Swift de $BUILD_ROOT: le recorta todo lo que venga después de "DerivedData/" + # y busca ahí SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run. snapshot compila + # en una carpeta temporal (/var/folders/.../snapshot_derived*), que no tiene ese tramo en + # la ruta, así que el recorte no hace nada y el script termina buscando los checkouts + # dentro de Build/Products, donde xcodebuild no los deja: la fase se cae con + # "PhaseScriptExecution FlutterFire: upload-crashlytics-symbols" y no llega a correr ni + # un test. Con la carpeta fija se puede dejar el enlace donde el script lo espera, que es + # lo mismo que hace el lane de macOS. + derived_data = File.join(root, "build", "ios-capturas") + productos = File.join(derived_data, "Build", "Products") + FileUtils.mkdir_p(productos) + FileUtils.ln_sf(File.join(derived_data, "SourcePackages"), File.join(productos, "SourcePackages")) + + # Antes de compilar, snapshot pregunta por los ajustes con `xcodebuild -showBuildSettings`, + # y eso en este proyecto resuelve primero todos los paquetes de Swift (Firebase incluido): + # en un runner frío no alcanza a responder en los 3 segundos que fastlane espera por + # defecto, ni en los 4 reintentos que hace doblando el plazo. La palanca es esta variable. + ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] ||= "180" + + # snapshot compila con xcodebuild, que no sabe nada de `--dart-define`. `--config-only` + # no compila: deja el punto de entrada y los defines en ios/Flutter/Generated.xcconfig, + # y de ahí los toma la fase de compilación de Flutter cuando xcodebuild construye la app. + # + # 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", "build", "ios", + "--config-only", + "--simulator", + "--debug", + "--flavor=#{scheme}", + "--target=#{scheme == 'production' ? 'lib/main_prod.dart' : 'lib/main_dev.dart'}", + "--dart-define=SCREENSHOT_MODE=true" + ) + devices.each do |device| - udid = ios_simulator_udid(device) - UI.message("📱 Preparando #{device} (#{udid})") + udid, nombre = ios_simulator(device) + UI.message("📱 Preparando #{nombre} (#{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'}" + # El resto de las opciones (scheme, configuración, xcargs) están en fastlane/Snapfile. + # El nombre del simulador va resuelto, no el prefijo con el que se pidió: snapshot + # busca el dispositivo por nombre exacto y así se elige el runtime más nuevo. + capture_ios_screenshots( + workspace: File.join(root, "ios", "Runner.xcworkspace"), + devices: [nombre], + languages: [locale], + output_directory: screenshots_dir, + scheme: scheme, + configuration: "Debug-#{scheme}", + derived_data_path: derived_data, ) + + # Un test que se cuelga y agota el tiempo igual deja el lane en verde 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 de #{nombre}. Revisa el log de snapshot.") if capturas.empty? + + 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") + + # frameit no falla cuando no puede leer los títulos (pasa si el locale no es UTF-8): + # simplemente no enmarca nada, y las capturas sin marco no sirven para la ficha. + enmarcadas = Dir.glob(File.join(output_dir, "*_framed.png")) + UI.user_error!("❌ frameit no generó ninguna captura enmarcada de #{nombre}.") if enmarcadas.empty? + end + + Dir.glob(File.join(output_dir, "*.png")).each { |captura| FileUtils.mv(captura, listas) } + # Apagarlo libera la RAM del runner para el siguiente simulador (y para el emulador de Android). + sh("xcrun simctl shutdown #{udid} || true") 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? + Dir.glob(File.join(listas, "*.png")).each { |captura| FileUtils.mv(captura, output_dir) } + FileUtils.remove_entry(listas) + capturas = Dir.glob(File.join(output_dir, "*.png")) 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)" diff --git a/lib/core/mock/mock_data.dart b/lib/core/mock/mock_data.dart index fbb09e7..799d08d 100644 --- a/lib/core/mock/mock_data.dart +++ b/lib/core/mock/mock_data.dart @@ -73,26 +73,85 @@ final Asignatura practicaProfesional = Asignatura( intentos: 1, ); +final Asignatura computacionWebYMovil = Asignatura( + id: "1004", + codigo: "INFB8004", + nombre: "Computación Web y Móvil", + tipoHora: "Cátedra", + estado: "Inscrito", + seccion: "304", + docente: Persona(nombreCompleto: "Ignacio Salinas Bravo"), + tipoAsignatura: "Obligatoria", + sala: "M1 - 303", + intentos: 1, +); + +final Asignatura gestionDeProyectos = Asignatura( + id: "1005", + codigo: "INFB8003", + nombre: "Gestión de Proyectos Informáticos", + tipoHora: "Cátedra", + estado: "Inscrito", + seccion: "301", + docente: Persona(nombreCompleto: "Marcela Tapia Reyes"), + tipoAsignatura: "Obligatoria", + sala: "M2 - 201", + intentos: 1, +); + +final Asignatura electivoEspecializacion = Asignatura( + id: "1006", + codigo: "ELEC8050", + nombre: "Electivo de Formación Especializada II", + tipoHora: "Cátedra", + estado: "Inscrito", + seccion: "101", + docente: Persona(nombreCompleto: "Hernán Pimentel Torrejón"), + tipoAsignatura: "Electivo", + sala: "M3 - 303", + intentos: 1, +); + final List asignaturasMock = [ computacionEnLaNube, trabajoDeTitulo, practicaProfesional, + computacionWebYMovil, + gestionDeProyectos, + electivoEspecializacion, ]; /// 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. +/// +/// Seis asignaturas repartidas de lunes a viernes: el horario recibe un color por +/// asignatura, así que mientras más variadas las clases del día, más colorida sale la +/// captura. El lunes es el día de [fechaCapturas] y lleva cuatro ramos distintos, para +/// que ni el horario ni "Clases de Hoy" queden en un solo tono. final List<(int, int, Asignatura)> _clasesMock = [ + // Lunes (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), + (2, 1, computacionWebYMovil), + (4, 1, gestionDeProyectos), + (5, 1, electivoEspecializacion), + (6, 1, electivoEspecializacion), + // Martes + (1, 2, computacionEnLaNube), + (3, 2, electivoEspecializacion), + (4, 2, computacionWebYMovil), + (6, 2, practicaProfesional), + // Miércoles + (1, 3, computacionEnLaNube), + (2, 3, gestionDeProyectos), + (4, 3, computacionWebYMovil), + (5, 3, trabajoDeTitulo), + // Jueves + (3, 4, practicaProfesional), + (4, 4, computacionWebYMovil), + (5, 4, trabajoDeTitulo), + // Viernes + (1, 5, gestionDeProyectos), + (3, 5, gestionDeProyectos), + (4, 5, electivoEspecializacion), ]; /// Horario de 18 medios bloques x 6 días, igual a la matriz que arma [HorarioService]. @@ -131,7 +190,7 @@ Grades notasMock(Asignatura asignatura) => switch (asignatura.codigo) { notaPresentacion: 6.6, notaFinal: 6.6, ), - _ => Grades( + "INFB900" => 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), @@ -139,6 +198,15 @@ Grades notasMock(Asignatura asignatura) => switch (asignatura.codigo) { notaPresentacion: 6.9, notaFinal: 6.9, ), + _ => Grades( + notasParciales: [ + REvaluacion(descripcion: "Solemne 1", porcentaje: 35, nota: 5.9), + REvaluacion(descripcion: "Solemne 2", porcentaje: 35, nota: 6.1), + REvaluacion(descripcion: "Trabajos", porcentaje: 30, nota: 6.7), + ], + notaPresentacion: 6.2, + notaFinal: 6.2, + ), }; AsignaturaMalla _aprobada(int nivel, String nombre, String nota, {String tipo = "Obligatoria"}) => @@ -150,22 +218,26 @@ AsignaturaMalla _inscrita(int nivel, String nombre, {String tipo = "Obligatoria" 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. +AsignaturaMalla _reprobada(int nivel, String nombre, String nota, {String tipo = "Obligatoria"}) => + AsignaturaMalla(nivel: nivel, nombre: nombre, tipo: tipo, intentos: 1, estado: "Reprobado", nota: nota); + +/// Malla de Ingeniería en Informática, con las asignaturas en curso inscritas y Trabajo +/// de Título II todavía sin cursar. Los niveles 1 y 2 llevan un ramo reprobado y dos +/// inscritos (se están cursando de nuevo) para que la captura muestre los cuatro estados. 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"), + _reprobada(1, "Taller de Matemática", "3,4"), + _inscrita(1, "Design Thinking"), // 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"), + _inscrita(2, "Mecánica Clásica"), _aprobada(2, "Álgebra Clásica", "4,4"), // Nivel 3 _aprobada(3, "Electromagnetismo", "4,1"), @@ -196,11 +268,11 @@ final List mallaMock = [ _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, "Electivo de Formación Especializada II", tipo: "Electivo"), _inscrita(7, "Trabajo de Título I"), - _aprobada(7, "Gestión de Proyectos Informáticos", "5,1"), + _inscrita(7, "Gestión de Proyectos Informáticos"), _inscrita(7, "Computación en la Nube"), - _aprobada(7, "Computación Web y Móvil", "4,5"), + _inscrita(7, "Computación Web y Móvil"), // Nivel 8 _noCursada(8, "Trabajo de Título II"), _inscrita(8, "Práctica Profesional"), diff --git a/lib/core/services/controllers/horario_controller.dart b/lib/core/services/controllers/horario_controller.dart index 46b72a8..6496497 100644 --- a/lib/core/services/controllers/horario_controller.dart +++ b/lib/core/services/controllers/horario_controller.dart @@ -56,7 +56,9 @@ class HorarioController { const Color(0xFFBA68C8), // Purple 300 ]; - final _randomColors = List.from(_pastelColors)..shuffle(); + // En modo capturas la paleta va en su orden original: así cada corrida reparte los mismos + // colores entre los ramos y las capturas de la tienda no cambian de una compilación a otra. + final _randomColors = modoCapturas ? List.from(_pastelColors) : (List.from(_pastelColors)..shuffle()); final _now = ahora(); num daysCount = 6; diff --git a/lib/core/services/service_manager.dart b/lib/core/services/service_manager.dart index 53c19e6..cc512ce 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:get_storage/get_storage.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"; @@ -31,6 +32,10 @@ Future initServices(FirebaseOptions firebaseOptions) async { return true; }; + // Sin esto, GetStorage lee y escribe sobre un mapa vacío que se reemplaza cuando termina + // de cargar el archivo: los colores del horario se perdían y todos los ramos salían celestes. + await GetStorage.init(); + Get.lazyPut(() => RemoteConfigService()); await Get.find().initialize(); diff --git a/lib/main.dart b/lib/main.dart index 5c63d13..d568eed 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,16 +3,27 @@ import "dart:io"; import "package:adaptive_theme/adaptive_theme.dart"; import "package:firebase_core/firebase_core.dart"; import "package:flutter/material.dart"; +import "package:flutter/semantics.dart"; import "package:flutter/services.dart"; import "package:miutem/core/models/preferencia.dart"; import "package:miutem/core/services/controllers/local_notifications_controller.dart"; import "package:miutem/core/services/service_manager.dart"; +import "package:miutem/core/utils/constants.dart"; import "package:miutem/core/utils/http/functions.dart"; import "package:miutem/styles/styles.dart"; void runMainApp(FirebaseOptions firebaseOptions) async { WidgetsFlutterBinding.ensureInitialized(); + // Las capturas de las tiendas las toman `fastlane snapshot` (UI Tests de Xcode) en iOS y + // `fastlane screengrab` (tests instrumentados con UI Automator) en Android, y los dos + // recorren la app por el árbol de accesibilidad. Flutter sólo lo publica cuando el + // sistema declara un cliente de accesibilidad activo: sin esto los tests se quedan + // mirando un lienzo vacío y no encuentran ni el botón de ingresar. + if (modoCapturas) { + SemanticsBinding.instance.ensureSemantics(); + } + if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { // Set window title to "Mi UTEM" SystemChrome.setApplicationSwitcherDescription( diff --git a/lib/widgets/feature_flag.dart b/lib/widgets/feature_flag.dart index 360231f..fc10303 100644 --- a/lib/widgets/feature_flag.dart +++ b/lib/widgets/feature_flag.dart @@ -120,6 +120,10 @@ class FeatureFlag extends StatelessWidget { /// Internal method to evaluate nested feature flags from the JSON structure static bool _evaluateNestedFlag(FirebaseRemoteConfig remoteConfig, String flagKey) { + // En modo capturas se muestran todas las características, incluidas las que estén + // apagadas en Remote Config: las capturas de las tiendas tienen que mostrarlas. + if (modoCapturas) return true; + try { // Get the feature_flags JSON from Remote Config final featureFlagsJson = remoteConfig.getString(RemoteConfigServiceKeys.featureFlags); diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 3cc05eb..a664d97 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -8,6 +8,16 @@ class MainFlutterWindow: NSWindow { self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) + // Tamaño exacto del área de Flutter, en puntos. Lo define el lane `mac screenshots` + // (MIUTEM_WINDOW_SIZE=1440x900) porque la Mac App Store sólo acepta capturas de + // 1280x800, 1440x900, 2560x1600 o 2880x1800: la ventana tiene que salir con esa forma. + let tamano = ProcessInfo.processInfo.environment["MIUTEM_WINDOW_SIZE"]? + .split(separator: "x").compactMap { Double($0) } ?? [] + if tamano.count == 2 { + self.setContentSize(NSSize(width: tamano[0], height: tamano[1])) + self.center() + } + RegisterGeneratedPlugins(registry: flutterViewController) super.awakeFromNib() diff --git a/macos/fastlane/Fastfile b/macos/fastlane/Fastfile index a090327..c82ed30 100644 --- a/macos/fastlane/Fastfile +++ b/macos/fastlane/Fastfile @@ -168,6 +168,117 @@ platform :mac do end end + # Las mismas pantallas que iOS y Android y el mismo acabado —fondo, titular y subtítulo—, + # pero el recorrido lo maneja `flutter drive` sobre integration_test/screenshots_test.dart: + # snapshot, que es con lo que se hacen las de iOS, recorre la app con los UI Tests de + # Xcode contra un simulador, y para macOS no hay simulador ni recorrido equivalente. + # + # El acabado tampoco pasa por frameit: su editor de Mac no dibuja marco y encima nunca + # corre, porque busca el dispositivo por el nombre "MacBook" y el de su lista es + # "Apple MacBook". Lo arma scripts/frame-macos-screenshots, que además mete la captura en + # la pantalla de un MacBook Pro 16 y compone las dos primeras capturas de la ficha. + desc "Genera las capturas de pantalla de macOS y las guarda en macos/fastlane/screenshots" + lane :screenshots do |options| + 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) + # Tamaño de la ventana en puntos. 1440x900 es una de las formas que acepta la tienda y + # el recorrido rasteriza al doble, así las capturas salen en 2880x1800. + size = string_option(options, :size, env_var: "SCREENSHOT_MACOS_SIZE", default: "1440x900") + + 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 Mac 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 + output_dir = File.join(root, "macos", "fastlane", "screenshots", locale) + + Dir.glob(File.join(output_dir, "*.png")).each { |captura| File.delete(captura) } if clear + FileUtils.mkdir_p(output_dir) + + # El job de capturas no sincroniza match, así que en CI no existe el perfil que el + # proyecto exige ("No profile for team 'X' matching 'match Development ... macos'"). Una + # compilación de debug que solo corre dentro del runner no necesita perfil: se firma + # ad-hoc. En local no se toca nada, ahí sí hay certificados. + if bool_option(options, :ad_hoc_signing, env_var: "IS_CI", default: false) + proyecto = Xcodeproj::Project.open(File.join(root, "macos", "Runner.xcodeproj")) + ([proyecto] + proyecto.targets).each do |objetivo| + objetivo.build_configurations.each do |config| + # Se borran también las variantes con condición (CODE_SIGN_IDENTITY[sdk=macosx*]), + # que ganan sobre la clave sin condición. + config.build_settings.delete_if { |clave, _| clave.start_with?("PROVISIONING_PROFILE", "DEVELOPMENT_TEAM") } + config.build_settings.keys.grep(/^CODE_SIGN_IDENTITY/).each { |clave| config.build_settings[clave] = "-" } + config.build_settings["CODE_SIGN_STYLE"] = "Manual" + end + end + proyecto.save + + # Los entitlements restringidos (keychain-access-groups y los com.apple.developer.*) + # solo valen si un perfil los autoriza. Firmados ad-hoc el kernel mata la app apenas + # parte, y `flutter drive` lo reporta como "Error waiting for a debug connection: The + # log reader stopped unexpectedly, or never started". Ninguno hace falta para el + # recorrido de capturas, que corre con datos ficticios. + restringidos = ["com.apple.developer.", "keychain-access-groups", "application-identifier", "com.apple.security.application-groups"] + ["DebugProfile.entitlements", "Release.entitlements"].each do |archivo| + ruta = File.join(root, "macos", "Runner", archivo) + entitlements = Xcodeproj::Plist.read_from_path(ruta) + entitlements.delete_if { |clave, _| restringidos.any? { |prefijo| clave.start_with?(prefijo) } } + Xcodeproj::Plist.write_to_path(entitlements, ruta) + end + + UI.important("🔓 Firma ad-hoc: las capturas no salen del runner.") + end + + ENV["SCREENSHOTS_DIR"] = output_dir + ENV["SCREENSHOT_PREFIX"] = "" + # La lee MainFlutterWindow.swift al abrir la ventana. + ENV["MIUTEM_WINDOW_SIZE"] = size + + # El paso de símbolos de Crashlytics que agrega flutterfire deduce la carpeta de + # paquetes de Swift desde $BUILD_ROOT (".../Build/Products") y busca ahí + # SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run. Eso calza con el + # DerivedData por defecto que usa iOS, pero `flutter build macos` pasa + # `-derivedDataPath build/macos` y ahí los checkouts quedan un nivel más arriba, así + # que el paso corta la compilación con "No such file or directory". El enlace deja el + # checkout donde el script lo espera (los lanes de despliegue no lo necesitan: compilan + # con xcodebuild y su DerivedData de siempre). + productos = File.join(root, "build", "macos", "Build", "Products") + FileUtils.mkdir_p(productos) + FileUtils.ln_sf(File.join(root, "build", "macos", "SourcePackages"), File.join(productos, "SourcePackages")) + + # 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", "macos", + "--dart-define=SCREENSHOT_MODE=true", + "--dart-define=SCREENSHOT_PROD=#{app_env == 'prod'}" + ) + + # 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 y una tira por captura, así + # que se generan después del recorrido: si cambia el tamaño de la ventana o el número + # de pantallas, las tiras dejarían de calzar y se perdería la continuidad de la foto. + sh(File.join(root, "scripts", "generate-screenshot-backgrounds"), "macos") + sh(File.join(root, "scripts", "frame-macos-screenshots"), locale) + + capturas = Dir.glob(File.join(output_dir, "*_framed.png")) + UI.user_error!("❌ No se generó ninguna captura enmarcada.") if capturas.empty? + end + + UI.success("✅ #{capturas.length} capturas #{frame ? 'enmarcadas' : 'guardadas'} en #{output_dir}") + 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/pubspec.yaml b/pubspec.yaml index 39c3f7c..c0873fc 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+153 +version: 4.0.0+163 environment: sdk: ^3.11.1 diff --git a/scripts/compose-store-screenshots b/scripts/compose-store-screenshots index c521a9c..8ae03a7 100755 --- a/scripts/compose-store-screenshots +++ b/scripts/compose-store-screenshots @@ -9,8 +9,8 @@ # # 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. +# La plataforma importa porque cada una se compone al terminar su propio recorrido: sin el +# argumento, el paso de iOS moriría buscando las capturas de Android y viceversa. set -euo pipefail cd "$(dirname "$0")/.." @@ -71,10 +71,19 @@ componer() { \( "$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) + # 2. El marco del teléfono, prestado de frameit. La salida se guarda en vez de tirarla: + # frameit es ruidoso cuando funciona, pero cuando no (le falta ImageMagick, no puede + # bajar los marcos) es lo único que dice por qué, y `set -e` cortaría acá sin abrir la boca. + local bitacora="$tmp/frameit.log" + (cd "$tmp" && fastlane frameit ${plataforma:+"$plataforma"} >"$bitacora" 2>&1) || true + local telefono="$tmp/${prefijo}splash_framed.png" - [ -f "$telefono" ] || { echo "❌ frameit no dejó el marco en $telefono" >&2; rm -rf "$tmp"; exit 1; } + if [ ! -f "$telefono" ]; then + echo "❌ frameit no dejó el marco en $telefono" >&2 + tail -20 "$bitacora" >&2 + rm -rf "$tmp" + exit 1 + fi # 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" diff --git a/scripts/frame-macos-screenshots b/scripts/frame-macos-screenshots new file mode 100755 index 0000000..18e9f91 --- /dev/null +++ b/scripts/frame-macos-screenshots @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# Enmarca las capturas de macOS como las de iOS y Android: el mismo fondo panorámico, el +# mismo par titular/subtítulo arriba, y la captura ocupando toda la pantalla de un MacBook +# Pro 16, para que se lea de una que la app es de Mac. Deja los *_framed.png al lado. +# +# Arma además las dos primeras capturas de la ficha —el MacBook en diagonal con la pantalla +# de splash, cruzando de una a la otra—, que no salen de la app. En iOS y Android las hace +# scripts/compose-store-screenshots; acá van en el mismo script porque el marco y los textos +# ya están cargados, y ese otro sólo sabe de teléfonos. +# +# No lo hace frameit, aunque tenga un MacEditor: ese editor sólo pega la captura sobre el +# fondo ("Macs don't need frames - backgrounds only") y encima nunca llega a correr, porque +# reconoce al Mac comparando el nombre del dispositivo con "MacBook" y el de su propia lista +# es "Apple MacBook"; la captura termina tratada como un iPhone sin marco y frameit se va +# sin escribir nada. El marco sí es el suyo: está bajado de frameit-frames. +# +# Se ejecuta después de `scripts/generate-screenshot-backgrounds macos`, que es quien deja +# las tiras de fondo con el nombre de cada captura. +# +# Uso: scripts/frame-macos-screenshots [locale] +set -euo pipefail + +cd "$(dirname "$0")/.." + +BASE="macos/fastlane/screenshots" +TEXTOS="fastlane/screenshots" # Los titulares son los de iOS: son las mismas pantallas. +FUENTE_TEXTO="fastlane/screenshots/fonts/Roboto.ttf" + +# El marco y el hueco de su pantalla, medido sobre ese archivo (es la única zona +# transparente de adentro). El hueco es 16:10, igual que las capturas de 2880x1800. +MARCO="fastlane/screenshots/frames/Apple MacBook Pro 16 Space Gray.png" +MARCO_ANCHO=3910 +MARCO_ALTO=2241 +PANTALLA_ANCHO=3072 +PANTALLA_ALTO=1920 +PANTALLA_X=419 +PANTALLA_Y=98 + +# Las medidas van en fracciones del alto de la captura —el lado corto, porque es apaisada— +# para que el resultado no dependa del tamaño en que se hayan tomado. +CUERPO_TITULAR=0.064 # Mismo cuerpo relativo que el Framefile de iOS (84 sobre 1320). +CUERPO_SUBTITULO=0.037 +COLOR_TITULAR="#FFFFFF" +COLOR_SUBTITULO="#CFE8DC" +MARGEN_SUPERIOR=0.055 # Del borde de arriba al titular. +ESPACIO_BAJO_TEXTO=0.05 # Del subtítulo al MacBook. +MARGEN_INFERIOR=0.08 # Tiene que dar para la sombra, o se corta contra el borde. +ANCHO_MACBOOK=0.86 # Ancho máximo del MacBook, en anchos de captura. +SOMBRA=0.022 # Desenfoque de la sombra. +SOMBRA_OPACIDAD=0.6 + +# La composición de las dos primeras, en fracciones del ancho (X) y del alto (Y) de una +# captura, contadas desde el borde izquierdo del par. +ROTACION=-12 # Grados de inclinación del MacBook. +ANCHO_DIAGONAL=0.68 # Ancho del MacBook antes de inclinarlo. +CENTRO_X=0.92 # Centro del MacBook: cruza la unión entre las dos capturas. +CENTRO_Y=0.50 +LOGO_ANCHO=0.28 # Ancho del logo de la UTEM. +LOGO_MARGEN=0.06 +TEXTO_CENTRO_X=1.60 # Centro del texto: en la mitad derecha, lejos de la esquina del +TEXTO_ARRIBA=0.14 # MacBook, que entra en la segunda captura. +SPLASH_LOGO_ALTO=0.26 # Alto del logo del splash y de la marca, en altos de captura. +SPLASH_MARCA_ALTO=0.06 +SPLASH_MARCA_ABAJO=0.07 + +# Los mismos assets con los que flutter_native_splash dibuja la pantalla de inicio. +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. +LOGO_UTEM="assets/images/utem_logo_color_blanco.png" # El blanco: va sobre el fondo oscuro. + +command -v magick >/dev/null || { echo "❌ Falta ImageMagick (brew install imagemagick)" >&2; exit 1; } +[ -f "$MARCO" ] || { echo "❌ Falta el marco $MARCO" >&2; exit 1; } +[ "$(magick identify -format "%wx%h" "$MARCO")" = "${MARCO_ANCHO}x${MARCO_ALTO}" ] || { + echo "❌ $MARCO ya no mide ${MARCO_ANCHO}x${MARCO_ALTO}: vuelve a medir el hueco de la pantalla." >&2 + exit 1 +} + +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")" +} + +# Mete una captura en la pantalla del MacBook, a pantalla completa. Sale con lo de alrededor +# transparente, para que la sombra y el fondo los ponga quien componga después. +en_macbook() { + magick -size "${MARCO_ANCHO}x${MARCO_ALTO}" xc:none \ + \( "$1" -resize "${PANTALLA_ANCHO}x${PANTALLA_ALTO}^" -gravity center \ + -extent "${PANTALLA_ANCHO}x${PANTALLA_ALTO}" \) \ + -gravity northwest -geometry "+$PANTALLA_X+$PANTALLA_Y" -composite \ + "$MARCO" -gravity northwest -geometry "+0+0" -composite "$2" +} + +# El par titular/subtítulo de una captura, con el mismo estilo del resto de las tiendas. +# El espacio entre los dos 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. +# +# Con un cuarto argumento se respetan los saltos de línea del .strings. Por defecto se +# aplanan: están puestos para las pantallas verticales de iOS y Android, y sobre una captura +# apaisada sobra ancho. La excepción es el par compuesto, donde el texto va en media captura. +texto_png() { + local clave=$1 ancho_max=$2 destino=$3 saltos=${4:-} + local titular subtitulo cuerpo_titular cuerpo_subtitulo + titular=$(texto_de "$TEXTOS/$LOCALE/keyword.strings" "$clave") + subtitulo=$(texto_de "$TEXTOS/$LOCALE/title.strings" "$clave") + [ -n "$titular" ] || { echo "❌ Falta el titular de $clave en $TEXTOS/$LOCALE/keyword.strings" >&2; exit 1; } + [ -n "$subtitulo" ] || { echo "❌ Falta el subtítulo de $clave en $TEXTOS/$LOCALE/title.strings" >&2; exit 1; } + [ -n "$saltos" ] || { titular=${titular//$'\n'/ }; subtitulo=${subtitulo//$'\n'/ }; } + + cuerpo_titular=$(calc "$ALTO * $CUERPO_TITULAR") + cuerpo_subtitulo=$(calc "$ALTO * $CUERPO_SUBTITULO") + magick -background none -font "$FUENTE_TEXTO" \ + \( -fill "$COLOR_TITULAR" -pointsize "$cuerpo_titular" -gravity center label:"$titular" \) \ + \( -fill "$COLOR_SUBTITULO" -pointsize "$cuerpo_subtitulo" -gravity center label:"$subtitulo" \ + -gravity north -splice "0x$((cuerpo_titular / 2))" \) \ + -gravity center -append +repage \ + -resize "${ancho_max}x>" \ + "$destino" +} + +# La sombra del MacBook, dentro de un margen fijo para saber cuánto crece el conjunto y +# poder centrarlo después. Deja el resultado en $tmp/conjunto.png y $margen con el margen. +con_sombra() { + local desenfoque caida ancho_pieza alto_pieza + desenfoque=$(calc "$ALTO * $SOMBRA") + caida=$(calc "$desenfoque * 0.8") + margen=$(calc "$desenfoque * 2.5 + $caida") + read -r ancho_pieza alto_pieza <<<"$(magick identify -format "%w %h" "$1")" + magick -size "$((ancho_pieza + margen * 2))x$((alto_pieza + margen * 2))" xc:none \ + \( "$1" -fill black -colorize 100 \ + -channel A -evaluate multiply "$SOMBRA_OPACIDAD" +channel \ + -bordercolor none -border "$margen" -blur "0x$desenfoque" \) \ + -gravity northwest -geometry "+0+$caida" -composite \ + "$1" -gravity northwest -geometry "+$margen+$margen" -composite \ + "$tmp/conjunto.png" +} + +LOCALE="${1:-es-MX}" +DIR="$BASE/$LOCALE" + +capturas=() +while IFS= read -r captura; do capturas+=("$captura"); done \ + < <(find "$DIR" -name "*.png" ! -name "*_framed.png" 2>/dev/null | sort) +[ "${#capturas[@]}" -gt 0 ] || { echo "❌ No hay capturas en $DIR: genéralas antes." >&2; exit 1; } + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +# Todas las capturas salen del mismo recorrido y miden lo mismo; el tamaño de la primera +# manda sobre las medidas relativas y sobre el par de capturas compuestas. +read -r ANCHO ALTO <<<"$(magick identify -format "%w %h" "${capturas[0]}")" + +for captura in "${capturas[@]}"; do + clave=$(basename "$captura" .png) + + fondo="$BASE/backgrounds/$clave.jpg" + [ -f "$fondo" ] || fondo="$BASE/background.jpg" + [ -f "$fondo" ] || { echo "❌ Falta el fondo de $clave: corre generate-screenshot-backgrounds macos" >&2; exit 1; } + + # 1. La captura dentro del MacBook y el texto de arriba. + en_macbook "$captura" "$tmp/macbook.png" + texto_png "$clave" "$(calc "$ANCHO * 0.9")" "$tmp/texto.png" + + # 2. El MacBook, achicado a lo que queda libre bajo el texto. + alto_texto=$(magick identify -format "%h" "$tmp/texto.png") + arriba_texto=$(calc "$ALTO * $MARGEN_SUPERIOR") + arriba_macbook=$((arriba_texto + alto_texto + $(calc "$ALTO * $ESPACIO_BAJO_TEXTO"))) + alto_libre=$((ALTO - arriba_macbook - $(calc "$ALTO * $MARGEN_INFERIOR"))) + magick "$tmp/macbook.png" -resize "$(calc "$ANCHO * $ANCHO_MACBOOK")x${alto_libre}" "$tmp/macbook_final.png" + + con_sombra "$tmp/macbook_final.png" + + # 3. Todo junto sobre la tira de fondo que le toca a esta captura. + read -r ancho_conjunto _ <<<"$(magick identify -format "%w %h" "$tmp/conjunto.png")" + magick "$fondo" -resize "${ANCHO}x${ALTO}^" -gravity center -extent "${ANCHO}x${ALTO}" \ + "$tmp/conjunto.png" -gravity northwest \ + -geometry "+$(((ANCHO - ancho_conjunto) / 2))+$((arriba_macbook - margen))" -composite \ + "$tmp/texto.png" -gravity north -geometry "+0+$arriba_texto" -composite \ + "$DIR/${clave}_framed.png" +done + +# Las dos primeras capturas de la ficha: el MacBook en diagonal con el splash, sobre las dos +# tiras de fondo puestas lado a lado, y el par cortado por la mitad al final. +magick -size "${ANCHO}x${ALTO}" "xc:$SPLASH_FONDO" \ + \( "$SPLASH_LOGO" -resize "x$(calc "$ALTO * $SPLASH_LOGO_ALTO")" \) -gravity center -composite \ + \( "$SPLASH_MARCA" -resize "x$(calc "$ALTO * $SPLASH_MARCA_ALTO")" \) -gravity south \ + -geometry "+0+$(calc "$ALTO * $SPLASH_MARCA_ABAJO")" -composite \ + "$tmp/splash.png" + +en_macbook "$tmp/splash.png" "$tmp/macbook_splash.png" +magick "$tmp/macbook_splash.png" -background none \ + -resize "$(calc "$ANCHO * $ANCHO_DIAGONAL")x" -rotate "$ROTACION" "$tmp/diagonal.png" +con_sombra "$tmp/diagonal.png" + +texto_png "02_bienvenida" "$(calc "$ANCHO * 0.8")" "$tmp/texto.png" saltos + +read -r ancho_conjunto alto_conjunto <<<"$(magick identify -format "%w %h" "$tmp/conjunto.png")" +magick "$BASE/backgrounds/01_bienvenida.jpg" "$BASE/backgrounds/02_bienvenida.jpg" +append \ + "$tmp/conjunto.png" -gravity northwest \ + -geometry "$(printf "%+d%+d" \ + "$(calc "$ANCHO * $CENTRO_X - $ancho_conjunto / 2")" \ + "$(calc "$ALTO * $CENTRO_Y - $alto_conjunto / 2")")" -composite \ + \( "$LOGO_UTEM" -resize "$(calc "$ANCHO * $LOGO_ANCHO")x" \) -gravity southwest \ + -geometry "+$(calc "$ANCHO * $LOGO_MARGEN")+$(calc "$ALTO * $LOGO_MARGEN")" -composite \ + "$tmp/texto.png" -gravity north \ + -geometry "$(printf "%+d%+d" "$(calc "$ANCHO * ($TEXTO_CENTRO_X - 1)")" "$(calc "$ALTO * $TEXTO_ARRIBA")")" \ + -composite \ + "$tmp/bienvenida.png" + +magick "$tmp/bienvenida.png" -crop "${ANCHO}x${ALTO}+0+0" +repage "$DIR/01_bienvenida_framed.png" +magick "$tmp/bienvenida.png" -crop "${ANCHO}x${ALTO}+${ANCHO}+0" +repage "$DIR/02_bienvenida_framed.png" + +echo "✅ ${#capturas[@]} capturas enmarcadas y 2 compuestas en $DIR" diff --git a/scripts/generate-screenshot-backgrounds b/scripts/generate-screenshot-backgrounds index e76195b..411e39f 100755 --- a/scripts/generate-screenshot-backgrounds +++ b/scripts/generate-screenshot-backgrounds @@ -3,7 +3,7 @@ # 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] +# Uso: scripts/generate-screenshot-backgrounds [ios|android|macos|all] set -euo pipefail cd "$(dirname "$0")/.." @@ -31,14 +31,28 @@ panoramica() { -quality 88 "$destino" } -# generar +# Capturas ya tomadas de una carpeta, en el orden en que van en la ficha. El `|| true` es +# para la carpeta que todavía no existe: con `pipefail` el find fallido cortaría el script +# sin decir nada, y quien pregunta por las capturas es justamente quien sabe si faltan. +capturas_de() { + find "$1" -name "*.png" ! -name "*_framed.png" 2>/dev/null | sort || true +} + +# generar [carpeta de textos] +# +# Las claves y su orden salen de title.strings. macOS no tiene el suyo: reusa el de iOS, +# porque son las mismas pantallas y la ficha lleva las mismas ocho capturas. generar() { local base=$1 locale=$2 ancho=$3 alto=$4 + # En su propia línea: bash declara todos los locales de una misma sentencia antes de + # evaluar las asignaciones, así que un `${5:-$base/$locale}` puesto arriba se expande + # cuando `base` todavía no existe y con `set -u` corta el script con "unbound variable". + local textos=${5:-$base/$locale} # 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) + muestra=$(capturas_de "$base/$locale" | head -1) if [ -n "$muestra" ]; then read -r ancho alto <<<"$(magick identify -format "%w %h" "$muestra")" fi @@ -46,9 +60,9 @@ generar() { # 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 '"') + < <(grep -o '^"[^"]*"' "$textos/title.strings" | tr -d '"') local n=${#claves[@]} - [ "$n" -gt 0 ] || { echo "❌ $base/$locale/title.strings no tiene claves" >&2; exit 1; } + [ "$n" -gt 0 ] || { echo "❌ No hay claves para los fondos de $base/$locale: revisa las capturas y title.strings" >&2; exit 1; } local tiras="$base/backgrounds" rm -rf "$tiras" && mkdir -p "$tiras" @@ -72,9 +86,10 @@ generar() { PLATAFORMA="${1:-all}" case "$PLATAFORMA" in - ios|android|all) ;; - *) echo "❌ Uso: $(basename "$0") [ios|android|all]" >&2; exit 1 ;; + ios|android|macos|all) ;; + *) echo "❌ Uso: $(basename "$0") [ios|android|macos|all]" >&2; exit 1 ;; esac -[ "$PLATAFORMA" = android ] || generar "fastlane/screenshots" "es-MX" 1320 2868 -[ "$PLATAFORMA" = ios ] || generar "android/fastlane/screenshots" "es-419" 1080 2160 +case "$PLATAFORMA" in ios|all) generar "fastlane/screenshots" "es-MX" 1320 2868 ;; esac +case "$PLATAFORMA" in android|all) generar "android/fastlane/screenshots" "es-419" 1080 2160 ;; esac +case "$PLATAFORMA" in macos|all) generar "macos/fastlane/screenshots" "es-MX" 2880 1800 "fastlane/screenshots/es-MX" ;; esac diff --git a/scripts/pr-screenshots-comment b/scripts/pr-screenshots-comment new file mode 100755 index 0000000..61ac42c --- /dev/null +++ b/scripts/pr-screenshots-comment @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Comenta en la PR qué capturas de pantalla generó el workflow. + +GitHub no deja adjuntar archivos a un comentario desde la API, así que las imágenes no se +pueden mostrar embebidas: el comentario lleva el detalle de lo que salió en cada +plataforma —el nombre y el tamaño de cada captura— y el enlace para descargar el artefacto. + +El comentario se actualiza en vez de duplicarse: se reconoce por la marca invisible que +lleva al principio. + +Uso: scripts/pr-screenshots-comment + +Variables de entorno (las define el workflow): +- GITHUB_TOKEN: token con permiso de escritura en pull requests +- GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SERVER_URL, GITHUB_API_URL: las da Actions +- PR_NUMERO: número de la pull request +- RESULTADO_IOS, RESULTADO_ANDROID, RESULTADO_MACOS: resultado de cada job +""" + +import json +import os +import pathlib +import sys +import urllib.error +import urllib.request + +MARCA = "" + +# Artefacto, título y de qué job depende. El orden es el del comentario. +PLATAFORMAS = [ + ("capturas-iphone", "📱 iPhone", "RESULTADO_IOS"), + ("capturas-ipad", "📱 iPad", "RESULTADO_IOS"), + ("capturas-android", "🤖 Android", "RESULTADO_ANDROID"), + ("capturas-macos", "🖥️ macOS", "RESULTADO_MACOS"), +] + + +def resolucion(png: pathlib.Path) -> str: + """Ancho×alto leídos de la cabecera IHDR, que en un PNG siempre va al principio.""" + with png.open("rb") as archivo: + cabecera = archivo.read(24) + + if len(cabecera) < 24 or cabecera[:8] != b"\x89PNG\r\n\x1a\n": + return "?" + + ancho = int.from_bytes(cabecera[16:20], "big") + alto = int.from_bytes(cabecera[20:24], "big") + return f"{ancho}×{alto}" + + +def crudas(carpeta: pathlib.Path) -> list[pathlib.Path]: + """Las capturas tal cual salen del simulador o del emulador. + + Se dejan fuera las *_framed.png, que son las de la ficha de la tienda: acá interesa + cómo quedó la app, no cómo queda el marco. + """ + return sorted(png for png in carpeta.rglob("*.png") if not png.name.endswith("_framed.png")) + + +def seccion(titulo: str, resultado: str, carpeta: pathlib.Path, enlace: str | None) -> str | None: + """Bloque de una plataforma, o None si su job no llegó a correr.""" + if resultado == "skipped" or resultado == "": + return None + + # La carpeta puede existir y estar vacía: cuando el artefacto no está, el paso que lo + # descarga la crea igual antes de fallar. + capturas = crudas(carpeta) if carpeta.is_dir() else [] + if not capturas: + motivo = "el job falló antes de generarlas" if resultado == "failure" else "no quedó ningún artefacto" + return f"### {titulo}\n\n❌ Sin capturas: {motivo}." + + filas = "\n".join(f"| `{png.name}` | {resolucion(png)} |" for png in capturas) + lineas = [ + f"### {titulo}", + "", + f"{len(capturas)} captura{'s' if len(capturas) != 1 else ''}:", + "", + "| Captura | Tamaño |", + "| --- | --- |", + filas, + ] + + if enlace: + lineas += ["", f"📦 [Descargar el artefacto]({enlace}) (incluye también las enmarcadas para la tienda)"] + if resultado == "failure": + lineas += ["", "⚠️ El job terminó con error: puede que falten capturas."] + + return "\n".join(lineas) + + +def cuerpo(raiz: pathlib.Path, enlaces: dict[str, str], run_url: str) -> str | None: + """Comentario completo, o None si no hay nada que contar.""" + secciones = [ + bloque + for artefacto, titulo, variable in PLATAFORMAS + if (bloque := seccion(titulo, os.environ.get(variable, ""), raiz / artefacto, enlaces.get(artefacto))) + ] + + if not secciones: + return None + + return "\n\n".join([ + MARCA, + "## 📸 Capturas de pantalla", + "Así quedó la app con los datos de demostración. Las imágenes no se pueden mostrar acá " + f"—GitHub no deja adjuntarlas desde la API—, así que van en los artefactos del " + f"[run]({run_url}).", + *secciones, + ]) + + +def pedir(url: str, token: str, metodo: str = "GET", datos: dict | None = None) -> dict | list: + peticion = urllib.request.Request( + url, + method=metodo, + data=json.dumps(datos).encode() if datos else None, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + ) + + with urllib.request.urlopen(peticion) as respuesta: + return json.loads(respuesta.read() or "{}") + + +def main() -> int: + raiz = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "capturas") + token = os.environ["GITHUB_TOKEN"] + repo = os.environ["GITHUB_REPOSITORY"] + pr = os.environ["PR_NUMERO"] + run_id = os.environ["GITHUB_RUN_ID"] + api = os.environ.get("GITHUB_API_URL", "https://api.github.com") + server = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + + run_url = f"{server}/{repo}/actions/runs/{run_id}" + artefactos = pedir(f"{api}/repos/{repo}/actions/runs/{run_id}/artifacts?per_page=100", token) + enlaces = { + artefacto["name"]: f"{run_url}/artifacts/{artefacto['id']}" + for artefacto in artefactos.get("artifacts", []) + } + + texto = cuerpo(raiz, enlaces, run_url) + if not texto: + print("No corrió ninguna plataforma: no hay nada que comentar.") + return 0 + + # Se actualiza el comentario anterior en vez de encadenar uno nuevo por corrida. + comentarios = pedir(f"{api}/repos/{repo}/issues/{pr}/comments?per_page=100", token) + anterior = next((c for c in comentarios if MARCA in (c.get("body") or "")), None) + + if anterior: + pedir(f"{api}/repos/{repo}/issues/comments/{anterior['id']}", token, "PATCH", {"body": texto}) + print(f"Comentario actualizado: {anterior['html_url']}") + else: + nuevo = pedir(f"{api}/repos/{repo}/issues/{pr}/comments", token, "POST", {"body": texto}) + print(f"Comentario publicado: {nuevo['html_url']}") + + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except urllib.error.HTTPError as error: + print(f"❌ {error.code} {error.reason} en {error.url}", file=sys.stderr) + print(error.read().decode(errors="replace"), file=sys.stderr) + sys.exit(1) diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart index 965c407..9a937fb 100644 --- a/test_driver/integration_test.dart +++ b/test_driver/integration_test.dart @@ -5,7 +5,10 @@ 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`): +/// Sólo lo usan las capturas de macOS: las de iOS y Android las toman snapshot y +/// screengrab, que traen su propio recorrido nativo. +/// +/// Se configura con variables de entorno (las define el lane `mac screenshots`): /// - SCREENSHOTS_DIR: carpeta destino (por defecto fastlane/screenshots/es-MX) /// - SCREENSHOT_PREFIX: prefijo del archivo, usado para separar por dispositivo Future main() async {