PDM · DAW 2

Aula 27 · Semana 11 · Sexta, 20 de novembro de 2026 · 08:30 às 12:30

Workshop: polish de UI e primeira distribuição a testers

Teoria: distribuição a testers com Firebase App Distribution, TestFlight e testes internos do Google Play. Prática (três horas): tema claro e escuro, português e inglês, ícone e splash, build de release e instalação no telemóvel de outra pessoa através do Firebase App Distribution; demonstração do TestFlight pelo docente.

Repo: pdm-aula-27Pausa às 10:20

No fim desta aula consegues

Porque distribuir cedo

Para os testes: distribuir cedo dá feedback, instalação e crashes reais; o tester não sabe onde carregar, e é por isso que serve.

Analogia Distribuir a testers é a pré-estreia de um filme para um público convidado, antes da estreia. O Firebase App Distribution e o TestFlight são a sala de pré-estreia: sem bilheteira, só entra quem foi convidado, e os comentários voltam para a equipa a tempo de corrigir. A estreia é a loja, e aí o primeiro espectador já pagou bilhete.

Firebase App Distribution (1 de 2)

1. Código no repositório merge em main ou uma tag no CI ou no teu portátil 2. Build de release flutter build apk --release assinado com a tua chave 3. Upload no Firebase consola, CLI ou GitHub Actions notas de versão e grupo 4. Testers recebem email grupo turma-daw recebe o link com as notas de versão 5. Instalam (App Tester) autorizam a origem uma vez veem todas as versões 6. Feedback e crashes screenshot + comentário na app crashes agrupados por versão próxima versão TestFlight (iOS): o mesmo fluxo, mas pelo App Store Connect Precisa de Mac, conta Apple Developer paga e IPA assinado; os testers instalam pela app TestFlight.
Do repositório ao telemóvel do tester sem passar pela loja; o feedback fecha o ciclo na versão seguinte.

Para os testes: App Distribution entrega APKs a testers por email, sem loja; em iOS exige perfil ad hoc com UDIDs, o que torna o TestFlight preferível.

Firebase App Distribution (2 de 2): consola, CLI e Actions

Para os testes: distribuir a partir do CI garante builds reproduzíveis; a credencial do Firebase vai em Secrets, nunca no repositório.

TestFlight

Para os testes: TestFlight exige conta paga e Mac; internos até 100 sem revisão, externos até 10 000 com Beta App Review; builds expiram em 90 dias.

Google Play internal testing, e os três lado a lado

App DistributionTestFlightPlay internal testing
PlataformaAndroid e iOSiOSAndroid
TestersSem limite prático, por email e grupo100 internos, 10 000 externosAté 100, por email
RevisãoNenhumaBeta App Review para externosNenhuma; closed e open testing já passam por revisão
Precisa deProjeto FirebaseConta Apple paga e MacConta Play (25 USD) e AAB assinado
Caminho para produçãoNão éApp StoreClosed testing → open testing → produção

Contas pessoais novas no Play têm de ter 12 testers em closed testing durante 14 dias antes de poderem publicar em produção.

Para os testes: o internal testing do Play aceita até 100 testers sem revisão; closed e open testing são o caminho para produção.

Boas práticas de distribuição

Para os testes: nunca distribuir builds de debug; cada build com número de versão novo e notas de versão.

Workshop: da app no teu telemóvel à app no dos outros

git clone https://github.com/pdm-daw-2026/pdm-aula-27.git
cd pdm-aula-27/starter
flutter pub get
flutter run

O starter/ é uma versão do Tempo Açores com cores fixas, strings em português no código, ícone por defeito e sem splash; tem TODOs numerados de 1 a 8 e testes a falhar em test/. O solucao/ tem o estado final, com os .arb, a configuração do ícone e do splash e o workflow de distribuição.

BlocoTempoResultado
1. Tema claro e escuro30 minZero cores fixas; os dois temas em todos os ecrãs
2. i18n com gen-l10n40 minapp_pt.arb, app_en.arb, plurais, datas
3. Ícone e splash20 minÍcone e splash próprios no telemóvel
4. Build de release25 minAPK instalado no telemóvel de outro grupo
5. App Distribution40 minBuild distribuído à turma, feedback recebido

Bloco 1: tema claro e escuro a partir de uma semente

import 'package:flutter/material.dart';

class AppTheme {
  static const _semente = Color(0xFF0B63C9);

  static ThemeData claro() => _base(ColorScheme.fromSeed(seedColor: _semente));

  static ThemeData escuro() =>
      _base(ColorScheme.fromSeed(seedColor: _semente, brightness: Brightness.dark));

  static ThemeData _base(ColorScheme esquema) => ThemeData(
        useMaterial3: true,
        colorScheme: esquema,
        appBarTheme: AppBarTheme(
          backgroundColor: esquema.surface,
          foregroundColor: esquema.onSurface,
          centerTitle: false,
        ),
        cardTheme: const CardThemeData(margin: EdgeInsets.symmetric(horizontal: 16, vertical: 6)),
        textTheme: const TextTheme(
          headlineSmall: TextStyle(fontWeight: FontWeight.w700, letterSpacing: -0.5),
          bodyMedium: TextStyle(height: 1.4),
        ),
      );
}

Bloco 1: ligar ao MaterialApp e usar nos widgets

MaterialApp(
  theme: AppTheme.claro(),
  darkTheme: AppTheme.escuro(),
  themeMode: context.watch<DefinicoesNotifier>().temaModo, // system, light ou dark
  // ...
)
// Num widget: nunca Colors.blue, sempre o esquema do tema
class AvisoChip extends StatelessWidget {
  const AvisoChip({super.key, required this.nivel});
  final NivelAviso nivel;

  @override
  Widget build(BuildContext context) {
    final esquema = Theme.of(context).colorScheme;
    final (fundo, texto) = switch (nivel) {
      NivelAviso.vermelho => (esquema.errorContainer, esquema.onErrorContainer),
      NivelAviso.laranja || NivelAviso.amarelo => (esquema.tertiaryContainer, esquema.onTertiaryContainer),
      NivelAviso.verde => (esquema.secondaryContainer, esquema.onSecondaryContainer),
    };
    return Chip(
      label: Text(nivel.name, style: TextStyle(color: texto)),
      backgroundColor: fundo,
    );
  }
}

Bloco 1: o que interessa

Exercício 1 TODOs 1 e 2: corre a app com o telemóvel em modo escuro e caça as cores fixas (grep -rn "Colors\." lib/). Substitui todas pelo esquema. O teste test/tema_test.dart verifica que AppTheme.escuro().brightness é dark e que a AppBar usa surface.

Bloco 2: i18n com gen-l10n, a configuração

dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  intl: any            # a versão é fixada pelo flutter_localizations

flutter:
  uses-material-design: true
  generate: true       # liga o gen-l10n ao flutter pub get
arb-dir: lib/l10n
template-arb-file: app_pt.arb
output-localization-file: app_localizations.dart

Depois de flutter pub get, o gerador escreve lib/l10n/app_localizations.dart e um ficheiro por língua. Não se editam: regeneram-se sempre que um .arb muda.

Bloco 2: os ficheiros .arb

{
  "@@locale": "pt",
  "tituloApp": "Tempo Açores",
  "previsaoDias": "Previsão a {dias} dias",
  "@previsaoDias": {"placeholders": {"dias": {"type": "int"}}},
  "avisosAtivos": "{n, plural, =0{Sem avisos ativos} =1{1 aviso ativo} other{{n} avisos ativos}}",
  "@avisosAtivos": {"placeholders": {"n": {"type": "int"}}},
  "atualizadoHa": "Atualizado há {minutos} min",
  "@atualizadoHa": {"placeholders": {"minutos": {"type": "int"}}},
  "tentarDeNovo": "Tentar de novo",
  "semLigacao": "Sem ligação. A mostrar os últimos dados guardados.",
  "favoritos": "Favoritos",
  "definicoes": "Definições"
}
{
  "@@locale": "en",
  "tituloApp": "Azores Weather",
  "previsaoDias": "{dias}-day forecast",
  "avisosAtivos": "{n, plural, =0{No active warnings} =1{1 active warning} other{{n} active warnings}}",
  "atualizadoHa": "Updated {minutos} min ago",
  "tentarDeNovo": "Try again",
  "semLigacao": "No connection. Showing the last saved data.",
  "favoritos": "Favourites",
  "definicoes": "Settings"
}

Bloco 2: ligar e usar

import 'package:intl/intl.dart';
import 'package:tempo_acores/l10n/app_localizations.dart';

MaterialApp(
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: AppLocalizations.supportedLocales,   // [pt, en], lidos dos .arb
  onGenerateTitle: (context) => AppLocalizations.of(context)!.tituloApp,
  locale: definicoes.locale,                              // null: segue o telemóvel
  // ...
)

// Num widget
final l10n = AppLocalizations.of(context)!;
Text(l10n.previsaoDias(5));
Text(l10n.avisosAtivos(avisos.length));
Text(l10n.atualizadoHa(DateTime.now().difference(obtidoEm).inMinutes));

// Datas e números na língua ativa, com intl
final locale = Localizations.localeOf(context).toString();
Text(DateFormat.EEEE(locale).format(previsao.dia));       // "segunda-feira" / "Monday"
Text(NumberFormat.decimalPattern(locale).format(19.8));   // "19,8" / "19.8"

Bloco 2: o que interessa

Exercício 2 TODOs 3 a 5: extrai as strings dos ecrãs de previsão e de avisos para os .arb, com o plural dos avisos e o dia da semana por extenso na língua ativa. Muda a língua do telemóvel para inglês e confirma. O teste test/l10n_test.dart monta o ecrã com locale: Locale('en') e procura "Try again".

Bloco 3: ícone com flutter_launcher_icons

dev_dependencies:
  flutter_launcher_icons: ^0.14.3
  flutter_native_splash: ^2.4.4

flutter_launcher_icons:
  android: true
  ios: true
  image_path: "assets/icon/icon-1024.png"
  adaptive_icon_background: "#0B63C9"
  adaptive_icon_foreground: "assets/icon/icon-foreground.png"
  remove_alpha_ios: true
  min_sdk_android: 21
dart run flutter_launcher_icons

Bloco 3: splash com flutter_native_splash

flutter_native_splash:
  color: "#0B63C9"
  color_dark: "#0F141B"
  image: assets/splash/logo.png
  image_dark: assets/splash/logo-dark.png
  android_12:
    color: "#0B63C9"
    color_dark: "#0F141B"
    image: assets/splash/logo-android12.png
  android: true
  ios: true
dart run flutter_native_splash:create
Exercício 3 TODO 6: gera o ícone e o splash com as imagens em assets/icon/ e assets/splash/ (ou as vossas). Desinstala a app antes de instalar de novo: o Android guarda o ícone antigo em cache. Tira um screenshot do ecrã inicial com o ícone novo para o README.

Bloco 4: build de release e instalar no telemóvel de outro grupo

flutter build apk --release
ls -lh build/app/outputs/flutter-apk/app-release.apk
adb install -r build/app/outputs/flutter-apk/app-release.apk
  1. Sobe a versão primeiro: version: 0.3.0+3 no pubspec.yaml. O +3 é o versionCode do Android; tem de crescer a cada build distribuído.
  2. Build: 2 a 4 minutos na primeira vez. O APK fica em build/app/outputs/flutter-apk/app-release.apk, com cerca de 20 MB.
  3. Instala no telemóvel de um colega de outro grupo: por cabo com adb install, ou envia o ficheiro (Drive, Bluetooth, Nearby Share) e abre-o no telemóvel dele.
  4. Observa: pede-lhe que use a app dois minutos sem ajuda. Não expliques nada. Anota o que não percebeu.

Bloco 4: o que interessa

Exercício 4 TODO 7: versão 0.3.0+3, build de release, instalado no telemóvel de alguém de outro grupo. O que ele não percebeu vai para uma issue no vosso repositório com a etiqueta feedback.

Bloco 5: Firebase App Distribution na consola

  1. Projeto: em console.firebase.google.com, "Add project" com o nome tempo-acores-grupoNN. O Analytics pode ficar desligado.
  2. App Android: "Add app" → Android, com o package name igual ao applicationId em android/app/build.gradle.kts (por exemplo pt.uac.tempoacores.grupo01). O google-services.json só é preciso se usarem Crashlytics ou Analytics.
  3. Distribuir: menu "Run" → App Distribution → "Get started" → arrasta o app-release.apk.
  4. Testers: separador "Testers and groups" → grupo turma-daw com os emails dos colegas (o docente partilha a lista no Moodle).
  5. Release: adiciona o grupo, escreve notas ("v0.3.0: favoritos sincronizados e mapa. Sabemos que o offline no ecrã de avisos falha.") e "Distribute".
  6. Do lado do tester: email → aceitar convite → instalar a App Tester → instalar a app. Na consola vês quem aceitou e quem instalou.

Bloco 5: o mesmo pela CLI

npm install -g firebase-tools
firebase login
firebase apps:list --project tempo-acores-grupo01
firebase appdistribution:distribute build/app/outputs/flutter-apk/app-release.apk \
  --app 1:123456789012:android:0a1b2c3d4e5f6a7b \
  --groups turma-daw \
  --release-notes "v0.3.0: favoritos sincronizados e mapa. Testar: modo avião no ecrã de avisos."

Bloco 5: a partir do GitHub Actions

name: Distribuir
on:
  push:
    tags: ['v*']

jobs:
  android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '17' }
      - uses: subosito/flutter-action@v2
        with: { channel: stable, cache: true }
      - run: flutter pub get
      - run: flutter test
      - run: flutter build apk --release --dart-define=API_CLIENT_ID=${{ secrets.API_CLIENT_ID }}
      - uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{ secrets.FIREBASE_APP_ID }}
          serviceCredentialsFileContent: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }}
          groups: turma-daw
          file: build/app/outputs/flutter-apk/app-release.apk
          releaseNotes: ${{ github.ref_name }}

A service account vem de "Project settings → Service accounts → Generate new private key" no Firebase; o JSON inteiro vai para o secret FIREBASE_SERVICE_ACCOUNT. A partir daqui, git tag v0.3.0 && git push --tags distribui.

Bloco 5: o que interessa

Exercício 5 TODO 8: distribui o vosso build a pelo menos três pessoas fora do grupo, pela consola ou pela CLI. Cada tester envia um feedback pela App Tester. Extra: o workflow de distribuição por tag no repositório do projeto, com a service account em Secrets.

Demo do docente: TestFlight ao vivo

  1. Xcode → Runner → Signing & Capabilities: equipa e bundle id (pt.uac.tempoacores). Com "Automatically manage signing", o Xcode cria certificado e perfil.
  2. Build: flutter build ipa --release cria build/ios/archive/Runner.xcarchive e o .ipa em build/ios/ipa/. Demora 5 a 8 minutos.
  3. Upload: abrir o .xcarchive no Xcode → "Distribute App" → App Store Connect → Upload; ou a app Transporter com o .ipa. O processamento demora 5 a 15 minutos e chega um email.
  4. App Store Connect → TestFlight: o build aparece. Se pedir "Missing Compliance", é a pergunta sobre encriptação; com ITSAppUsesNonExemptEncryption a false no Info.plist deixa de perguntar.
  5. Testers internos: grupo "Equipa", adicionar utilizadores do App Store Connect; disponível de imediato. Externos: link público; o primeiro build de cada versão vai a Beta App Review.
  6. No iPhone: app TestFlight → aceitar → instalar. O feedback com screenshot chega ao App Store Connect.
Sem Mac? O Codemagic compila e faz o upload com o teu certificado numa máquina deles. É a alternativa para iOS sem hardware Apple; continua a precisar da conta paga.

Até segunda

Para fazer

  • Build do projeto distribuído a pelo menos 3 testers fora do grupo pelo App Distribution (ou TestFlight, quem tiver iOS), com notas de versão e feedback recebido.
  • Tema escuro, ícone, splash e as strings em pt e en no projeto, em PRs revistos. É o polish que conta na rubrica.
  • Segunda: platform channels e a migração para a API v2. Lê o changelog da API na página API antes da aula.

Para ler