Aula 27 · Semana 11 · Sexta, 20 de novembro de 2026 · 08:30 às 12:30
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.
ColorScheme e usar as cores do tema em vez de cores fixas..arb em pt e en, plurais e placeholders.flutter_launcher_icons e flutter_native_splash.Para os testes: distribuir cedo dá feedback, instalação e crashes reais; o tester não sabe onde carregar, e é por isso que serve.
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 appdistribution:distribute app-release.apk --app ID --groups turma-daw. Um comando, repetível, cabe num script.flutter build apk --release e distribui a cada tag ou a cada merge em main. A partir daí, nenhum build de produção sai do portátil de alguém.Para os testes: distribuir a partir do CI garante builds reproduzíveis; a credencial do Firebase vai em Secrets, nunca no repositório.
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.
| App Distribution | TestFlight | Play internal testing | |
|---|---|---|---|
| Plataforma | Android e iOS | iOS | Android |
| Testers | Sem limite prático, por email e grupo | 100 internos, 10 000 externos | Até 100, por email |
| Revisão | Nenhuma | Beta App Review para externos | Nenhuma; closed e open testing já passam por revisão |
| Precisa de | Projeto Firebase | Conta Apple paga e Mac | Conta Play (25 USD) e AAB assinado |
| Caminho para produção | Não é | App Store | Closed 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.
version: 1.2.0+47 no pubspec.yaml antes de cada distribuição. Dois builds com o mesmo número confundem testers e loja, e o Play recusa.Para os testes: nunca distribuir builds de debug; cada build com número de versão novo e notas de versão.
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.
| Bloco | Tempo | Resultado |
|---|---|---|
| 1. Tema claro e escuro | 30 min | Zero cores fixas; os dois temas em todos os ecrãs |
| 2. i18n com gen-l10n | 40 min | app_pt.arb, app_en.arb, plurais, datas |
| 3. Ícone e splash | 20 min | Ícone e splash próprios no telemóvel |
| 4. Build de release | 25 min | APK instalado no telemóvel de outro grupo |
| 5. App Distribution | 40 min | Build distribuído à turma, feedback recebido |
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),
),
);
}
MaterialApp e usar nos widgetsMaterialApp(
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,
);
}
}
ColorScheme.fromSeed gera as cerca de 30 cores do Material 3 a partir de uma só; a versão escura vem da mesma semente com Brightness.dark. Coerência grátis.Colors.white num Container fica branco no tema escuro e cega. Os pares surface/onSurface, primaryContainer/onPrimaryContainer garantem contraste.textTheme e usa Theme.of(context).textTheme.titleMedium. Não repitas TextStyle(fontSize: 18) em vinte sítios.ThemeMode.system por defeito; o toggle da aula 9 guarda a escolha em shared_preferences.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.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.
.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"
}
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"
Text('...') fixo é um bug de i18n à espera. Extrair todas as strings é trabalhoso; por isso faz-se cedo e não na véspera.=0, =1, other. Português e inglês são simples; russo e árabe têm mais categorias, e o intl conhece-as.@chave só é obrigatória no ficheiro template; as traduções só precisam das chaves. Chave em falta no en: o gerador avisa e usa o pt.DateFormat e o locale ativo. DateTime.toString() na UI é proibido.extension L10n on BuildContext { AppLocalizations get l10n => AppLocalizations.of(this)!; } poupa o ! em todo o lado..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".flutter_launcher_iconsdev_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
remove_alpha_ios). O adaptive icon do Android tem fundo e primeiro plano separados, e o launcher recorta (círculo, squircle) como quiser: deixa margem no primeiro plano.android/app/src/main/res/mipmap-* e ios/Runner/Assets.xcassets/AppIcon.appiconset. Não se edita à mão.flutter_native_splashflutter_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
android_12.dart run flutter_native_splash:remove.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.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
version: 0.3.0+3 no pubspec.yaml. O +3 é o versionCode do Android; tem de crescer a cada build distribuído.build/app/outputs/flutter-apk/app-release.apk, com cerca de 20 MB.adb install, ou envia o ficheiro (Drive, Bluetooth, Nearby Share) e abre-o no telemóvel dele.build.gradle assina o release com a chave de debug. Instala, mas o Play não aceita e todos os grupos partilham a mesma chave. A chave de release é matéria da aula 31.--split-per-abi gera um APK por arquitetura (arm64-v8a, armeabi-v7a, x86_64), cada um com um terço do tamanho. Para a loja, flutter build appbundle.--dart-define, como na aula 18. Confirma que não está no repositório antes de distribuir.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.tempo-acores-grupoNN. O Analytics pode ficar desligado.applicationId em android/app/build.gradle.kts (por exemplo pt.uac.tempoacores.grupo01). O google-services.json só é preciso se usarem Crashlytics ou Analytics.app-release.apk.turma-daw com os emails dos colegas (o docente partilha a lista no Moodle).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."
--app é o App ID que apps:list mostra (também está nas definições do projeto). Identifica o projeto e a app; não confundir com o package name.--testers "ana@x.pt,rui@y.pt" em vez de --groups para convidar pessoas avulsas; --release-notes-file notas.txt para notas longas.tool/distribuir.sh que sobe a versão, faz o build e distribui. Quem corre o script não precisa de saber Firebase.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.
versionCode são a mesma release. Sobe o +N antes de cada build.v0.3.0 dispara build e distribuição. O portátil de ninguém volta a ser a origem dos builds, e os testes correm antes.pt.uac.tempoacores). Com "Automatically manage signing", o Xcode cria certificado e perfil.flutter build ipa --release cria build/ios/archive/Runner.xcarchive e o .ipa em build/ios/ipa/. Demora 5 a 8 minutos..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.ITSAppUsesNonExemptEncryption a false no Info.plist deixa de perguntar.flutter build apk e appbundle, e o número de versão..arb, plurais.