You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Flutter — Transições de Tela com Animations e Hero: Cheatsheet Rápido
Comandos Essenciais
# Instalar dependências após adicionar o pacote animations
flutter pub get
# Rodar o app para visualizar as transições
flutter run
# Ativar slow animations via Flutter DevTools (no navegador, aba "Performance")# Ou via emulador Android: Configurações → Opções do desenvolvedor → Escala de animação → 10x# Ou via código, adicionando temporariamente no main():# timeDilation = 10.0; // requer import 'package:flutter/scheduler.dart'# Verificar erros após as modificações
flutter analyze
OpenContainer<bool>( // tipo de retorno opcional
transitionDuration:constDuration(milliseconds:400),
transitionType:ContainerTransitionType.fadeThrough,
closedElevation:2,
openElevation:0,
closedShape:RoundedRectangleBorder(
borderRadius:BorderRadius.circular(16),
),
closedColor:AppColors.cardBackground,
openColor:AppColors.background,
onClosed: (returnValue) {
// chamado quando o usuário fecha a tela aberta// returnValue é o valor retornado por closeContainer(value)
},
closedBuilder: (context, openContainer) {
returnInkWell(
onTap: openContainer,
child:MeuCard(),
);
},
openBuilder: (context, closeContainer) {
returnMinhaTelaDetalhe(
onFechar: () =>closeContainer(true),
);
},
)
Tipos de transição do ContainerTransform
Tipo
Comportamento
ContainerTransitionType.fade
Conteúdo do container some durante a expansão
ContainerTransitionType.fadeThrough
Novo conteúdo surge com fade no meio da transição
Hero
Uso básico (duas telas)
// Tela A (origem)Hero(
tag:'meu-elemento-unico', // tag deve ser idêntica em ambas as telas
child:MeuWidget(),
)
// Tela B (destino)Hero(
tag:'meu-elemento-unico',
child:MeuWidgetMaior(), // pode ter tamanho e forma diferentes
)
Hero em listas — tag dinâmica
// Use o ID do item para garantir unicidadeHero(
tag:'account-icon-${account.id}',
child:CircleAvatar(radius:20, child:Icon(Icons.account_balance)),
)
Regras importantes do Hero
✓ tag deve ser única na tela (dois Hero com o mesmo tag na mesma tela = erro)
✓ tag deve ser idêntica em origem e destino
✓ funciona com MaterialPageRoute, PageRouteBuilder e CupertinoPageRoute
✓ funciona com push, pushReplacement, pushAndRemoveUntil
✗ não funciona dentro de showDialog sem overlay customizado
✗ Hero dentro de Hero não é suportado
// 1. Declare a keyfinalGlobalKey<AnimatedListState> _listKey =GlobalKey<AnimatedListState>();
// 2. Use AnimatedList no lugar de ListViewAnimatedList(
key: _listKey,
initialItemCount: items.length,
itemBuilder: (context, index, animation) {
returnSlideTransition(
position:Tween<Offset>(
begin:constOffset(1, 0),
end:Offset.zero,
).animate(CurvedAnimation(parent: animation, curve:Curves.easeOut)),
child:MeuItem(items[index]),
);
},
)
// 3. Inserir com animação
_listKey.currentState?.insertItem(index,
duration:constDuration(milliseconds:350));
// 4. Remover com animação
_listKey.currentState?.removeItem(
index,
(context, animation) =>FadeTransition(
opacity: animation,
child:MeuItem(itemRemovido), // snapshot do item que está saindo
),
duration:constDuration(milliseconds:300),
);
Regras importantes
✓ initialItemCount deve coincidir com o tamanho da lista local no initState
✓ Sempre manter uma lista local (_localItems) sincronizada com o AnimatedList
✓ removeItem precisa de um builder para exibir o item enquanto ele sai
✓ insertItem e removeItem devem ser chamados APÓS alterar _localItems
✗ Não usar provider.items diretamente como fonte — o AnimatedList não detecta mudanças
Qual transição usar?
Usuário toca num card → abre tela de detalhe
└─► OpenContainer
Um ícone/imagem específico conecta origem e destino
└─► Hero (pode combinar com OpenContainer)
Troca de abas (BottomNavBar)
└─► FadeThroughTransition + PageTransitionSwitcher
Login → Dashboard (entrar no app)
└─► SharedAxisTransition.scaled
Passo 1 → Passo 2 → Passo 3 (wizard)
└─► SharedAxisTransition.horizontal
Lista → Detalhe sem elemento visual conectando
└─► SharedAxisTransition.vertical
Loading → Conteúdo carregado (na mesma tela)
└─► FadeThroughTransition + PageTransitionSwitcher
Acessibilidade — Reduce Motion
// Verificar preferência do usuáriofinal reduceMotion =MediaQuery.of(context).disableAnimations;
// SharedAxisTransition, FadeThroughTransition e OpenContainer// já respeitam disableAnimations automaticamente.// Se construir animações customizadas, verificar manualmente:final duration = reduceMotion
?Duration.zero
:constDuration(milliseconds:400);
Flutter — Distribuição Automatizada: Firebase App Distribution e CI/CD
Firebase App Distribution
Setup do projeto Firebase
console.firebase.google.com
→ Adicionar projeto → banco-douro-app
→ Registrar app Android: package name → baixar google-services.json
→ Registrar app iOS: bundle ID → baixar GoogleService-Info.plist
Conta de serviço Firebase
Firebase Console → ⚙️ Configurações → Contas de serviço
→ Gerar nova chave privada → salvar JSON
Google Cloud Console → IAM
→ Editar conta de serviço
→ Adicionar papel: Firebase App Distribution Admin
Grupos de testers
Firebase Console → App Distribution → Testers e grupos → Criar grupo
→ qa-team (equipe interna de QA)
→ stakeholders (aprovadores e gerentes)
Google Play Console → Configuração → Acesso à API
→ Criar novo projeto de serviço
→ Google Cloud Console → Criar conta de serviço → Baixar JSON
→ Google Play Console → Conceder permissão: Release Manager
Variáveis no Codemagic — grupo google_play
GCLOUD_SERVICE_ACCOUNT_CREDENTIALS → JSON da conta de serviço (secure)
PACKAGE_NAME → com.bancodouro.app
App Store Connect → Usuários e Acesso → Chaves → API Keys
→ Gerar Chave de API → Role: App Manager
→ Baixar .p8 (disponível para download apenas uma vez)
→ Anotar: Key ID e Issuer ID
Variáveis no Codemagic — grupo app_store
APP_STORE_CONNECT_KEY_IDENTIFIER → Key ID
APP_STORE_CONNECT_ISSUER_ID → Issuer ID
APP_STORE_CONNECT_PRIVATE_KEY → conteúdo do .p8 (secure)
Seção publishing.app_store_connect no codemagic.yaml
publishing:
app_store_connect:
api_key: $APP_STORE_CONNECT_PRIVATE_KEYkey_id: $APP_STORE_CONNECT_KEY_IDENTIFIERissuer_id: $APP_STORE_CONNECT_ISSUER_IDsubmit_to_testflight: true # envia para TestFlight após uploadsubmit_to_app_store: false # nunca automatizar sem aprovação humana
codemagic.yaml — Workflows Completos desta Aula
workflows:
# CI: roda em todo push e PRci-tests:
name: CI — Análise e Testesinstance_type: mac_mini_m1triggering:
events:
- push
- pull_requestenvironment:
flutter: stableworking_directory: banco_douro_appscripts:
- name: Dependênciasscript: flutter pub get
- name: Análise estáticascript: flutter analyze --fatal-infos
- name: Formataçãoscript: dart format --set-exit-if-changed .
- name: Testesscript: flutter test --coverage# CD Staging: push main → Firebase App Distribution → qa-teamcd-staging:
name: CD — Staging (Firebase App Distribution)instance_type: mac_mini_m1triggering:
events:
- pushbranch_patterns:
- pattern: maininclude: trueenvironment:
flutter: stablevars:
FIREBASE_ANDROID_APP_ID: "1:123456789:android:abc123"groups:
- firebase_credentials
- android_signingworking_directory: banco_douro_appscripts:
- name: Dependênciasscript: flutter pub get
- name: Testesscript: flutter test
- name: Restaurar google-services.jsonscript: | echo "$GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > \ android/app/google-services.json
- name: Build APKscript: | flutter build apk --release \ --build-number=$BUILD_NUMBERartifacts:
- banco_douro_app/build/app/outputs/flutter-apk/app-release.apkpublishing:
firebase:
firebase_service_account: $FIREBASE_SERVICE_ACCOUNT_CREDENTIALSandroid:
app_id: $FIREBASE_ANDROID_APP_IDgroups:
- qa-teamartifact_type: apkrelease_notes:
- language: pt-brtext: "Build $BUILD_NUMBER — Branch: $CM_BRANCH — Commit: $CM_COMMIT"# CD Produção Android: tag v* → Google Play (internal track)cd-production-android:
name: CD — Produção Android (Google Play)instance_type: mac_mini_m1triggering:
events:
- tagtag_patterns:
- pattern: "v*"include: trueenvironment:
flutter: stablegroups:
- google_play
- android_signingworking_directory: banco_douro_appscripts:
- name: Dependênciasscript: flutter pub get
- name: Testesscript: flutter test
- name: Build AAB produçãoscript: | flutter build appbundle --release \ --build-number=$BUILD_NUMBERartifacts:
- banco_douro_app/build/app/outputs/bundle/release/app-release.aabpublishing:
google_play:
credentials: $GCLOUD_SERVICE_ACCOUNT_CREDENTIALStrack: internalsubmit_as_draft: false# CD Produção iOS: tag v* → App Store Connect (TestFlight)cd-production-ios:
name: CD — Produção iOS (App Store)instance_type: mac_mini_m1triggering:
events:
- tagtag_patterns:
- pattern: "v*"include: trueenvironment:
flutter: stablegroups:
- app_store
- ios_signingworking_directory: banco_douro_appscripts:
- name: Dependênciasscript: flutter pub get
- name: Testesscript: flutter test
- name: Build IPA produçãoscript: | flutter build ipa --release \ --build-number=$BUILD_NUMBER \ --export-options-plist=/Users/builder/export_options.plistartifacts:
- banco_douro_app/build/ios/ipa/*.ipapublishing:
app_store_connect:
api_key: $APP_STORE_CONNECT_PRIVATE_KEYkey_id: $APP_STORE_CONNECT_KEY_IDENTIFIERissuer_id: $APP_STORE_CONNECT_ISSUER_IDsubmit_to_testflight: truesubmit_to_app_store: false
Trigger por Evento — Pipeline Completo
Evento
Workflow
Destino
push (qualquer branch)
ci-tests
— (só valida)
pull_request
ci-tests
— (só valida)
push para main
cd-staging
Firebase App Distribution → qa-team
tag v*
cd-production-android
Google Play (internal track)
tag v*
cd-production-ios
App Store Connect (TestFlight)
Como criar e publicar uma tag de release
git tag v1.0.0
git push origin v1.0.0
Comparação: Firebase App Distribution vs. Lojas
Aspecto
Firebase App Distribution
Google Play / App Store
Público
Testers internos e QA
Usuários finais
Revisão
Nenhuma
Obrigatória
Tempo até o tester
Segundos
Horas a dias
Rastreamento de instalação
Sim
Parcialmente (Play Console)
Versão permanente
Não (expira)
Sim
Feedback in-app
Sim (screenshot + comentário)
Via reviews públicas
Restrição iOS
Dispositivo precisa estar no profile Ad Hoc
Apenas necessita conta Apple
Checklist desta Aula
FIREBASE APP DISTRIBUTION
✅ Projeto Firebase criado
✅ App Android e iOS registrados no Firebase
✅ google-services.json e GoogleService-Info.plist no .gitignore
✅ Conta de serviço Firebase com role App Distribution Admin
✅ FIREBASE_SERVICE_ACCOUNT_CREDENTIALS como secret no Codemagic
✅ GOOGLE_SERVICES_JSON_BASE64 como secret no Codemagic
✅ Grupos qa-team e stakeholders criados no Firebase Console
✅ Workflow cd-staging com publishing.firebase no codemagic.yaml
✅ Push para main → Firebase → testers notificados por e-mail
GOOGLE PLAY
✅ Conta de serviço Google Play com role Release Manager
✅ GCLOUD_SERVICE_ACCOUNT_CREDENTIALS como secret no Codemagic
✅ Workflow cd-production-android com publishing.google_play
✅ Pipeline: tag v* → Google Play internal track
APP STORE CONNECT
✅ API Key gerada (.p8 guardado — disponível para download apenas uma vez)
✅ Key ID e Issuer ID anotados
✅ APP_STORE_CONNECT_* como secrets no Codemagic
✅ Workflow cd-production-ios com publishing.app_store_connect
✅ Pipeline: tag v* → App Store Connect (TestFlight)
Resumo de Conceitos
Conceito
Definição rápida
Firebase App Distribution
Distribuição de builds para testers sem revisão de loja
Conta de serviço
Credencial de serviço com escopo limitado — padrão para CI/CD
Grupos de testers
Organizar testers por papel (qa-team, stakeholders)
publishing.firebase
Upload automático para Firebase App Distribution via Codemagic
publishing.google_play
Publicação automática na Google Play via Codemagic
publishing.app_store_connect
Publicação automática na App Store via Codemagic
Trigger por tag v*
Portão humano deliberado antes do lançamento para produção
track: internal
Track da Google Play sem revisão pública — para validar o fluxo de CD
submit_to_testflight: true
Enviar para TestFlight automaticamente após upload no App Store Connect
submit_to_app_store: false
Manter aprovação manual para o submit final à App Store
# Rodar app
flutter run
flutter run -d chrome # Web
flutter run -d macos # macOS
flutter run -d <device-id># Dispositivo especifico# Atalhos durante execucao
r # Hot Reload (recarrega codigo)
R # Hot Restart (reinicia app)
q # Sair
// CORRETO - verificar mountedvoid_loadData() async {
final data =awaitfetchData();
if (mounted) { // SEMPRE verificar!setState(() {
_data = data;
});
}
}
// ERRADO - async dentro do setStatesetState(() async {
_data =awaitfetchData(); // NUNCA fazer isso!
});
final _formKey =GlobalKey<FormState>();
// Validar todos os camposbool isValid = _formKey.currentState!.validate();
// Salvar (chama onSaved de cada campo)
_formKey.currentState!.save();
// Resetar formulario
_formKey.currentState!.reset();
// Substituir tela atualNavigator.pushReplacement(context, route);
Navigator.pushReplacementNamed(context, '/home');
// Remover todas ate condicaoNavigator.pushAndRemoveUntil(
context,
route,
(route) =>false, // Remove todas
);
// Pode voltar?Navigator.canPop(context)
for (int i =0; i < items.length; i++) {
final start = i *0.1;
final end = start +0.3;
_animations[i] =Tween<double>(begin:0, end:1).animate(
CurvedAnimation(
parent: _controller,
curve:Interval(
start,
end.clamp(0.0, 1.0),
curve:Curves.easeOut,
),
),
);
}
// watch() - Reconstroi quando muda (usar no build)Widgetbuild(BuildContext context) {
final counter = context.watch<CounterProvider>();
returnText('${counter.count}');
}
// read() - Sem rebuild (usar em callbacks)void_increment() {
context.read<CounterProvider>().increment();
}
User Action (tap, input)
|
v
context.read<Provider>().method()
|
v
Provider modifica estado
|
v
notifyListeners()
|
v
Widgets com watch()/Consumer rebuildam
|
v
UI atualizada
Checklist da Aula
Criar e usar ValueNotifier
Usar ValueListenableBuilder corretamente
Criar ChangeNotifier para Provider
Configurar ChangeNotifierProvider
Usar context.watch() e context.read() corretamente
// Sem argumentosfinal result =await platform.invokeMethod('getBatteryLevel');
// Com argumentosfinal result =await platform.invokeMethod('saveData', {
'key':'username',
'value':'Joao',
});
// Com tipo generico (recomendado)finalint? result =await platform.invokeMethod<int>('getBatteryLevel');
// intfinalint? level =await platform.invokeMethod<int>('getLevel');
final safeLevel = level ??-1;
// StringfinalString? name =await platform.invokeMethod<String>('getName');
final safeName = name ??'unknown';
// Map (SEMPRE converter)final result =await platform.invokeMethod('getInfo');
final map =Map<String, dynamic>.from(result asMap);
// List de Mapsfinal result =await platform.invokeMethod('getList');
final list = (result asList)
.map((item) =>Map<String, dynamic>.from(item asMap))
.toList();
// Nativo -> Flutterfinal result =await platform.invokeMethod('getWorkout');
final workout =Map<String, dynamic>.from(result asMap);
final exercises = (workout['exercises'] asList)
.map((e) =>Map<String, dynamic>.from(e asMap))
.toList();
No Android (Kotlin)
// Receber dados complexosval workout = call.arguments as?HashMap<*, *>
val name = workout?.get("name") as?Stringval exercises = workout?.get("exercises") as?ArrayList<*>
// Retornar dados complexosval result =HashMap<String, Any>()
result["total"] =42
result["items"] =arrayListOf(
hashMapOf("name" to "A", "value" to 1),
hashMapOf("name" to "B", "value" to 2),
)
result.success(result)
No iOS (Swift)
// Receber dados complexos
letworkout= call.arguments as?[String:Any]letname=workout?["name"]as?Stringletexercises=workout?["exercises"]as?[[String:Any]]
// Retornar dados complexos
letresultData:[String:Any]=["total":42,"items":[["name":"A","value":1],["name":"B","value":2],]]result(resultData)
// ERRADO - nomes diferentes!// Dart:MethodChannel('com.app/native')
// Kotlin:
val CHANNEL="com.app/nativo"// DIFERENTE!// CERTO - mesmo nome em ambos// Dart e Kotlin usam: "com.app/native"
Nao converter Map
// ERRADO - Map<dynamic, dynamic>final result =await platform.invokeMethod('getInfo');
final brand = result['brand']; // Pode falhar!// CERTO - converter explicitamentefinal result =await platform.invokeMethod('getInfo');
final map =Map<String, dynamic>.from(result asMap);
final brand = map['brand'] asString;
@overridevoidbinds(Injector i) {
// Factory - nova instancia a cada chamada
i.add(MyController.new);
// Singleton - instancia unica (imediata)
i.addSingleton(MyService.new);
// Lazy Singleton - instancia unica (no primeiro uso)
i.addLazySingleton(MyRepository.new);
// Instancia existente
i.addInstance(ApiClient(baseUrl:'https://api.com'));
}
Metodo
Quando Cria
Instancias
i.add
Cada chamada
Multiplas
i.addSingleton
Na inicializacao
Uma
i.addLazySingleton
Primeiro uso
Uma
i.addInstance
Ja criada
Uma
Consumir Dependencias
// Obter instancia (lanca erro se nao existir)final service =Modular.get<ExerciseService>();
// Obter nullablefinal service =Modular.tryGet<ExerciseService>();
// Com valor padraofinal service =Modular.get<ExerciseService>(
defaultValue:ExerciseService(),
);
Injecao Automatica no Construtor
classExerciseController {
finalExerciseService service;
ExerciseController(this.service); // Injetado automaticamente!
}
// No modulo
i.addSingleton(ExerciseService.new);
i.add(ExerciseController.new); // service resolvido automaticamente
// Na rota de destinofinal args =Modular.args;
final id = args.params['id']; // parametro da URLfinal query = args.queryParams['q']; // query stringfinal data = args.data; // arguments passados
Boas Praticas
Pacotes
// BOM - API publica explicitalibrary fittracker_core;
export'src/models/exercise.dart';
// RUIM - Expor tudoexport'src/internal_helper.dart'; // Codigo interno!
Modular
// BOM - Dependencia no escopo corretoclassExerciseModuleextendsModule {
@overridevoidbinds(Injector i) {
i.addLazySingleton(ExerciseService.new); // Apenas neste modulo
}
}
// RUIM - Tudo no AppModuleclassAppModuleextendsModule {
@overridevoidbinds(Injector i) {
i.addSingleton(ExerciseService.new);
i.addSingleton(TimerService.new);
i.addSingleton(AuthService.new);
// 30+ servicos no modulo raiz...
}
}
Organizacao de Modulos
// BOM - Um modulo por feature
r.module('/auth', module:AuthModule());
r.module('/exercises', module:ExerciseModule());
r.module('/timer', module:TimerModule());
// RUIM - Tudo misturado em um modulo
r.child('/login', child: (_) =>LoginPage());
r.child('/exercises', child: (_) =>ExerciseListPage());
r.child('/timer', child: (_) =>TimerPage());
// 50+ child routes...
External ──> Infra ──> Domain <── Presenter
Domain NAO depende de ninguem.
Infra implementa interfaces do Domain.
External implementa interfaces da Infra.
Presenter consome UseCases do Domain.
Acoplamento vs Desacoplamento
// ACOPLADO - tela conhece banco diretamenteclassMyPage {
final database =AppDatabase(); // dependencia concreta!
}
// DESACOPLADO - tela conhece apenas abstracaoclassMyPage {
finalGetExercises useCase; // dependencia abstrata!MyPage(this.useCase);
}
import'package:mockito/mockito.dart';
// Mock manual - sem @GenerateMocks, sem build_runnerclassMockExerciseRepositoryextendsMockimplementsExerciseRepository {}
voidmain() {
lateMockExerciseRepository mockRepo;
setUp(() {
mockRepo =MockExerciseRepository();
});
}
Configurar Retorno
// Retorno com sucesso (Future)when(mockRepo.getAll()).thenAnswer(
(_) async=> [exercise1, exercise2],
);
// Retorno sincronowhen(mockRepo.getAll()).thenReturn([exercise]);
// Simular errowhen(mockRepo.getAll()).thenThrow(Exception('Erro'));
// Aceitar qualquer argumentowhen(mockRepo.getById(any)).thenAnswer((_) async=>null);
when(mockRepo.add(any)).thenAnswer((_) async {});
Verificar Chamadas
// Foi chamado 1 vezverify(mockRepo.getAll()).called(1);
// Nunca foi chamadoverifyNever(mockRepo.remove(any));
// Nenhuma outra interacaoverifyNoMoreInteractions(mockRepo);
find.text('Exercicios'); // Por texto
find.byType(ElevatedButton); // Por tipo
find.byIcon(Icons.add); // Por icone
find.byKey(Key('my_key')); // Por Key
find.widgetWithText(ListTile, 'Supino'); // Widget com texto
Expectations
expect(find.text('Titulo'), findsOneWidget); // Exatamente 1expect(find.text('Erro'), findsNothing); // Nenhumexpect(find.byType(Card), findsNWidgets(5)); // Exatamente Nexpect(find.byType(Card), findsWidgets); // Pelo menos 1