graph LR
subgraph LTR["Texto LTR (pt-BR, en-US)"]
direction LR
A1[Icone] --> B1[Texto] --> C1[Seta >]
end
subgraph RTL["Texto RTL (ar, he)"]
direction RL
A2["< Seta"] --> B2[טקסט] --> C2[סמל]
end
Módulo 14 — Internacionalização e Acessibilidade
Você chegou a um módulo que transforma seu aplicativo de um produto para uma única comunidade em uma solução para o mundo. Até aqui, o aplicativo de delivery que você construiu faz coisas impressionantes: se comunica com o backend na AWS, autentica usuários com segurança usando OAuth 2.0, envia notificações push via Firebase, acessa a câmera do dispositivo para atualizar fotos de perfil e obtém a localização do usuário para sugerir o endereço de entrega. É um aplicativo funcional, robusto e moderno.
Mas ele ainda tem duas limitações que, em um contexto profissional, seriam barreiras para o sucesso: ele fala apenas um idioma, e ele não foi projetado para ser usado por pessoas com deficiências visuais ou motoras. Esses dois aspectos — a internacionalização e a acessibilidade — não são detalhes cosméticos adicionados no final do projeto. São decisões de arquitetura que afetam como você escreve código desde o início, e é por isso que existe um módulo inteiro dedicado a eles.
Internacionalizar um aplicativo significa prepará-lo para funcionar em qualquer idioma, com qualquer formato de data, hora, número e moeda, respeitando as convenções culturais de cada região. Tornar um aplicativo acessível significa construí-lo de forma que pessoas com limitações visuais, auditivas, motoras ou cognitivas possam utilizá-lo com a mesma eficiência que qualquer outro usuário. Essas duas competências são cada vez mais exigidas pelo mercado — e cada vez mais valorizadas como evidência de maturidade profissional.
Neste módulo, você vai aprender a usar o pacote i18n_extension para internacionalizar o aplicativo de forma elegante e prática, a biblioteca intl para formatar dados de acordo com o locale do usuário, e o widget Semantics para tornar a interface legível por leitores de tela. Ao final, você terá um aplicativo que pode ser usado por uma pessoa no Brasil, outra nos Estados Unidos e uma terceira que usa o TalkBack no Android para navegar pelo aplicativo sem enxergar a tela.
Seção 1 — O que são Internacionalização, Localização e Globalização
Antes de escrever uma linha de código, vale a pena entender com precisão o que cada um desses três termos significa — porque eles são frequentemente confundidos ou usados de forma intercambiável no mercado, mas têm significados distintos e complementares.
Internacionalização: preparando o terreno
A internacionalização, frequentemente abreviada como i18n (porque há 18 letras entre o “i” e o “n” na palavra “internationalization”), é o processo de projetar e construir um aplicativo de forma que ele possa ser adaptado para diferentes idiomas e regiões sem precisar alterar o código-fonte central da aplicação. Pense na internacionalização como a construção da infraestrutura — você instala tomadas e encanamentos que podem suportar diferentes voltagens e pressões, mas a adaptação específica para cada país vem depois.
Um aplicativo internacionalizado não contém textos com valores fixos diretamente no código. Em vez de escrever Text('Bem-vindo!'), você escreve algo equivalente a Text('welcome_message'.i18n), onde 'welcome_message'.i18n é uma chave que o sistema substitui pelo texto correspondente ao idioma configurado. A internacionalização é o trabalho feito pelo desenvolvedor para que esse mecanismo funcione.
Outro aspecto da internacionalização vai além dos textos: é preciso preparar o código para lidar com diferentes formatos de data (no Brasil usamos “dd/MM/yyyy”, nos Estados Unidos usam “MM/dd/yyyy”), diferentes separadores decimais (no Brasil usamos vírgula — R$ 12,50 — enquanto nos EUA usam ponto — $12.50), diferentes sistemas de ordenação de strings (o que afeta como listas são ordenadas alfabeticamente), diferentes direções de leitura (idiomas como árabe e hebraico são escritos da direita para a esquerda), e diferentes convenções de pluralização (em português, “1 item” e “2 itens” — em russo, as regras de pluralização são muito mais complexas, com formas diferentes para 1, 2–4, 5–20 e assim por diante).
Localização: preenchendo com conteúdo
A localização, abreviada como l10n (10 letras entre o “l” e o “n” de “localization”), é o processo de criar o conteúdo real para cada idioma ou região específica. Se a internacionalização é instalar as tomadas, a localização é fornecer os plugues e adaptadores de cada país. A localização inclui traduzir os textos da interface para cada idioma suportado, adaptar imagens e ícones que podem ter conotações culturais diferentes, e ajustar formatos específicos de cada região.
A localização geralmente envolve mais pessoas além do desenvolvedor — tradutores profissionais, especialistas em cultura local e revisores. Um bom processo de localização considera não apenas a tradução palavra por palavra, mas a adaptação da mensagem para que ela faça sentido no contexto cultural do público-alvo. Um botão que diz “Finalizar Pedido” em português pode precisar de uma tradução mais longa em alemão (“Bestellung abschließen”), e o layout da interface precisa ser capaz de acomodar essa variação de comprimento.
Globalização: a visão de conjunto
A globalização é o conceito que engloba tanto a internacionalização quanto a localização, além de considerações de negócio, legais e culturais mais amplas. No contexto do desenvolvimento de software, globalização é a estratégia completa de tornar um produto relevante e funcional para mercados globais. Enquanto a internacionalização e a localização são responsabilidades técnicas do desenvolvedor, a globalização envolve decisões de produto, marketing e estratégia empresarial.
Para os objetivos deste módulo, o que importa diretamente é a internacionalização (preparar o código) e a localização (fornecer as traduções). A globalização é o contexto mais amplo que justifica por que você está fazendo esse trabalho.
Por que isso importa para o aplicativo de delivery
Imagine que o aplicativo de delivery seja lançado comercialmente e começa a crescer. Um cliente que viajou do exterior para o Brasil tenta usar o aplicativo mas encontra todos os textos em português. Um usuário que migrou recentemente configura seu Android em espanhol, mas o aplicativo permanece em português. Uma pessoa com deficiência visual tenta usar o aplicativo, mas o leitor de tela anuncia apenas “botão” em vez de “Adicionar produto ao carrinho”.
Cada um desses cenários representa um usuário perdido — e no mercado real, cada usuário perdido é uma receita perdida. Mais do que isso, construir um aplicativo acessível é, em muitos países, uma obrigação legal. O Brasil, por exemplo, tem a Lei Brasileira de Inclusão (Lei nº 13.146/2015) que estabelece que serviços digitais devem ser acessíveis. Ignorar acessibilidade não é apenas uma decisão técnica ruim — é, potencialmente, uma questão jurídica.
Seção 2 — O Pacote i18n_extension
O Flutter tem uma solução oficial de internacionalização baseada nos pacotes flutter_localizations e intl, com geração de código a partir de arquivos .arb. Essa abordagem funciona bem, mas tem uma curva de aprendizagem considerável: você precisa configurar um arquivo l10n.yaml, criar arquivos .arb para cada idioma, rodar o gerador de código e referenciar os textos por meio de classes geradas automaticamente como AppLocalizations.of(context).welcomeMessage. Para aplicativos grandes com equipes de tradutores, essa abordagem estruturada faz muito sentido. Para projetos como o da disciplina, ela adiciona uma camada de complexidade desnecessária.
O i18n_extension oferece uma alternativa mais pragmática. Com ele, você anota as strings diretamente no código com um operador intuitivo, e as traduções ficam organizadas em arquivos de extensão por idioma. Não há geração de código, não há configuração especial de build, e a adição de um novo idioma é tão simples quanto criar um novo arquivo de tradução e registrá-lo.
Instalando e configurando
Para adicionar o pacote ao projeto, adicione a dependência no pubspec.yaml:
O pacote intl não é uma dependência direta do i18n_extension, mas você vai precisar dele para formatação de datas, horas, números e moedas — por isso, já o adicionamos aqui.
Após rodar flutter pub get, o próximo passo é configurar o MaterialApp para que ele reconheça as localizações suportadas e delegue a resolução de textos ao i18n_extension. Isso é feito alterando o widget raiz da aplicação:
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:i18n_extension/i18n_extension.dart';
class MeuApp extends StatelessWidget {
const MeuApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
// Idioma padrão da aplicação
locale: const Locale('pt', 'BR'),
// Lista de idiomas que a aplicação suporta
supportedLocales: const [
Locale('pt', 'BR'), // Português do Brasil
Locale('en', 'US'), // Inglês dos Estados Unidos
Locale('es', ''), // Espanhol
],
// Delegates de localização necessários para internacionalizar
// os widgets do próprio Flutter (como DatePicker, CupertinoDatePicker, etc.)
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
// Permite que o i18n_extension detecte automaticamente
// o idioma do sistema operacional.
localeResolutionCallback: (locale, supportedLocales) {
if (locale != null) {
for (final suportado in supportedLocales) {
if (suportado.languageCode == locale.languageCode) {
return suportado;
}
}
}
// Idioma padrão caso nenhum suportado seja encontrado
return const Locale('pt', 'BR');
},
home: const TelaInicial(),
);
}
}import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
class MeuApp extends StatelessWidget {
const MeuApp({super.key});
static const _locales = [
Locale('pt', 'BR'),
Locale('en', 'US'),
Locale('es'),
];
@override
Widget build(BuildContext context) => MaterialApp(
locale: _locales.first,
supportedLocales: _locales,
localizationsDelegates: GlobalMaterialLocalizations.delegates,
localeResolutionCallback: (locale, supported) => supported.firstWhere(
(s) => s.languageCode == locale?.languageCode,
orElse: () => _locales.first,
),
home: const TelaInicial(),
);
}A estrutura de arquivos de tradução
O i18n_extension organiza as traduções em arquivos Dart — não em arquivos de recursos externos como .arb ou .json. Essa é uma característica que pode parecer estranha à primeira vista, mas tem uma vantagem importante: você tem toda a expressividade da linguagem Dart disponível para lidar com casos especiais, como pluralização e interpolação de variáveis.
A convenção recomendada é criar uma pasta lib/l10n/ (ou lib/i18n/) onde ficam os arquivos de tradução. Para o aplicativo de delivery, um arquivo de tradução básico tem a seguinte estrutura:
// lib/l10n/traducoes.dart
import 'package:i18n_extension/i18n_extension.dart';
// O objeto Translations centraliza todas as traduções de um arquivo.
// A chave é a string original (em português), e os valores são as
// traduções para cada idioma suportado.
const _traducoes = Translations.byText('pt_BR') +
{
// Tela de login
'Bem-vindo de volta': {
'en_US': 'Welcome back',
'es': 'Bienvenido de nuevo',
},
'Entrar': {
'en_US': 'Sign in',
'es': 'Iniciar sesión',
},
'Esqueceu sua senha?': {
'en_US': 'Forgot your password?',
'es': '¿Olvidó su contraseña?',
},
// Tela de pedidos
'Meus Pedidos': {
'en_US': 'My Orders',
'es': 'Mis Pedidos',
},
'Pedido em preparo': {
'en_US': 'Order being prepared',
'es': 'Pedido en preparación',
},
'Pedido entregue': {
'en_US': 'Order delivered',
'es': 'Pedido entregado',
},
// Tela de carrinho
'Adicionar ao carrinho': {
'en_US': 'Add to cart',
'es': 'Agregar al carrito',
},
'Finalizar Pedido': {
'en_US': 'Place Order',
'es': 'Realizar Pedido',
},
'Seu carrinho está vazio': {
'en_US': 'Your cart is empty',
'es': 'Tu carrito está vacío',
},
};
// Extensão que aplica as traduções às strings.
// Qualquer string do arquivo que chamar .i18n usará
// este objeto de traduções.
extension TraduzirString on String {
String get i18n => localize(this, _traducoes);
// Para interpolação com parâmetros:
String fill(List<Object> params) => localizeFill(this, params);
// Para pluralização:
String plural(int qtd) => localizePlural(qtd, this, _traducoes);
}// lib/l10n/traducoes.dart
import 'package:i18n_extension/i18n_extension.dart';
const _t = Translations.byText('pt_BR') +
{
'Bem-vindo de volta': {'en_US': 'Welcome back', 'es': 'Bienvenido de nuevo'},
'Entrar': {'en_US': 'Sign in', 'es': 'Iniciar sesión'},
'Esqueceu sua senha?': {'en_US': 'Forgot your password?', 'es': '¿Olvidó su contraseña?'},
'Meus Pedidos': {'en_US': 'My Orders', 'es': 'Mis Pedidos'},
'Pedido em preparo': {'en_US': 'Order being prepared', 'es': 'Pedido en preparación'},
'Pedido entregue': {'en_US': 'Order delivered', 'es': 'Pedido entregado'},
'Adicionar ao carrinho': {'en_US': 'Add to cart', 'es': 'Agregar al carrito'},
'Finalizar Pedido': {'en_US': 'Place Order', 'es': 'Realizar Pedido'},
'Seu carrinho está vazio': {'en_US': 'Your cart is empty', 'es': 'Tu carrito está vacío'},
};
extension Tr on String {
String get i18n => localize(this, _t);
String fill(List<Object> p) => localizeFill(this, p);
String plural(int n) => localizePlural(n, this, _t);
}Seção 3 — O Operador .i18n em Ação
A maneira como o i18n_extension faz com que as strings sejam traduzidas é elegante: ele adiciona um getter chamado i18n diretamente ao tipo String por meio de uma extension. Quando você chama 'algum texto'.i18n, a extension busca esse texto no objeto Translations registrado e retorna a versão traduzida para o idioma corrente. Se não houver tradução, retorna o próprio texto original — o que é um comportamento muito conveniente durante o desenvolvimento, quando nem todas as traduções foram adicionadas ainda.
Tradução simples
O uso mais básico é direto: basta adicionar .i18n ao final de qualquer string que precise ser traduzida. No arquivo do widget, você importa a extension e usa o operador normalmente:
// lib/features/autenticacao/presentation/widgets/tela_login.dart
import 'package:flutter/material.dart';
import 'package:delivery/l10n/traducoes.dart'; // importa a extension
class TelaLogin extends StatelessWidget {
const TelaLogin({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// O operador .i18n substitui a string pela tradução correspondente
// ao idioma atual da aplicação.
title: Text('Bem-vindo de volta'.i18n),
),
body: Column(
children: [
// Botão de login com texto traduzido
ElevatedButton(
onPressed: () {},
child: Text('Entrar'.i18n),
),
// Link de recuperação de senha com texto traduzido
TextButton(
onPressed: () {},
child: Text('Esqueceu sua senha?'.i18n),
),
],
),
);
}
}import 'package:flutter/material.dart';
import 'package:delivery/l10n/traducoes.dart';
class TelaLogin extends StatelessWidget {
const TelaLogin({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text('Bem-vindo de volta'.i18n)),
body: Column(
children: [
ElevatedButton(onPressed: () {}, child: Text('Entrar'.i18n)),
TextButton(
onPressed: () {},
child: Text('Esqueceu sua senha?'.i18n),
),
],
),
);
}Tradução com interpolação de variáveis
Em muitos casos, a mensagem a ser exibida não é estática — ela contém valores dinâmicos, como o nome do usuário, o número do pedido ou o valor a ser pago. O i18n_extension lida com isso por meio do método fill, que substitui os marcadores %s pelos valores fornecidos:
// Primeiro, registre a string com marcadores nas traduções:
// 'Olá, %s! Seu pedido #%s está a caminho.' => {
// 'en_US': 'Hello, %s! Your order #%s is on the way.',
// 'es': 'Hola, %s! Tu pedido #%s está en camino.',
// }
// No widget, use o método fill() para preencher os marcadores:
Text(
'Olá, %s! Seu pedido #%s está a caminho.'
.i18n
.fill([nomeUsuario, numeroPedido]),
)Tradução com pluralização
A pluralização é um dos aspectos mais delicados da internacionalização. Em português, “1 item no carrinho” e “3 itens no carrinho” têm formas distintas para singular e plural. Em inglês, a lógica é a mesma: “1 item in cart” e “3 items in cart”. Mas em russo, há quatro formas diferentes: uma para 1, uma para 2–4, uma para 5–20 e outra para o zero. O i18n_extension suporta pluralização com regras configuráveis por idioma:
// Na definição das traduções, use chaves numéricas para as formas:
// '0 itens no carrinho' => 0
// '1 item no carrinho' => 1
// '%s itens no carrinho' => 2 (forma padrão para qualquer número > 1)
const _traducoes = Translations.byText('pt_BR') +
{
'Um item no carrinho': {
'pt_BR': Plural(
one: '1 item no carrinho',
many: '%s itens no carrinho',
zero: 'Carrinho vazio',
),
'en_US': Plural(
one: '1 item in cart',
many: '%s items in cart',
zero: 'Cart is empty',
),
},
};
// No widget, use .plural() com a quantidade:
Text('Um item no carrinho'.plural(quantidadeItens))const _t = Translations.byText('pt_BR') +
{
'Um item no carrinho': {
'pt_BR': Plural(one: '1 item no carrinho', many: '%s itens no carrinho', zero: 'Carrinho vazio'),
'en_US': Plural(one: '1 item in cart', many: '%s items in cart', zero: 'Cart is empty'),
},
};
// Uso:
Text('Um item no carrinho'.plural(qtd))Seção 4 — Detecção Automática e Seletor Manual de Idioma
Há duas estratégias para determinar qual idioma o aplicativo deve usar: a detecção automática com base no idioma configurado no sistema operacional, e a seleção manual pelo próprio usuário dentro do aplicativo. A prática recomendada é combinar as duas: detectar automaticamente para oferecer uma boa experiência de primeira abertura, e permitir que o usuário altere o idioma nas configurações do aplicativo quando quiser.
Detecção automática do idioma do sistema
O Flutter já possui um mecanismo nativo para detectar o idioma do sistema operacional. O MaterialApp recebe um parâmetro locale que, quando omitido, é preenchido automaticamente pelo framework com base nas configurações do dispositivo. A função localeResolutionCallback que você viu na configuração inicial permite que você escreva a lógica de resolução: dado o idioma do sistema e a lista de idiomas suportados pelo seu aplicativo, qual idioma deve ser usado?
O comportamento mais razoável é o seguinte: se o idioma do sistema for português (independentemente de ser pt-BR, pt-PT ou qualquer outra variante), use português. Se for inglês, use inglês. Se for qualquer outro idioma que não está na lista de suportados, use o idioma padrão (português, no nosso caso).
Implementando um seletor de idioma
Permitir que o usuário altere o idioma dentro do aplicativo requer armazenar a preferência dele em algum lugar persistente. Você já aprendeu a usar SharedPreferences no Módulo 8 para armazenar preferências simples — este é exatamente o caso de uso ideal para essa ferramenta.
A alteração do idioma em tempo de execução, sem reiniciar o aplicativo, é uma das características que tornam o i18n_extension conveniente. Para que essa alteração se propague por toda a árvore de widgets, você precisa de um ChangeNotifier (que você aprendeu no Módulo 7) responsável por manter o locale atual e notificar os widgets quando ele mudar:
// lib/features/configuracoes/domain/preferencias_idioma_provider.dart
import 'package:flutter/material.dart';
import 'package:i18n_extension/i18n_extension.dart';
import 'package:shared_preferences/shared_preferences.dart';
class PreferenciasIdiomaProvider extends ChangeNotifier {
// Locale atual da aplicação
Locale _localeAtual = const Locale('pt', 'BR');
// Chave para armazenar a preferência no SharedPreferences
static const String _chaveIdioma = 'idioma_preferido';
Locale get localeAtual => _localeAtual;
// Lista de idiomas disponíveis no aplicativo
final List<Locale> idiomasDisponiveis = const [
Locale('pt', 'BR'),
Locale('en', 'US'),
Locale('es', ''),
];
// Carrega a preferência salva quando o aplicativo inicia
Future<void> carregarIdiomaSalvo() async {
final prefs = await SharedPreferences.getInstance();
final codigoSalvo = prefs.getString(_chaveIdioma);
if (codigoSalvo != null) {
final partes = codigoSalvo.split('_');
_localeAtual = partes.length == 2
? Locale(partes[0], partes[1])
: Locale(partes[0]);
// Notifica o i18n_extension sobre o idioma carregado
I18n.define(_localeAtual);
// Informa os widgets que escutam este provider
notifyListeners();
}
}
// Altera o idioma e persiste a escolha
Future<void> alterarIdioma(Locale novoLocale) async {
if (_localeAtual == novoLocale) return;
_localeAtual = novoLocale;
// Registra o novo locale no i18n_extension para que todas as
// chamadas .i18n passem a retornar textos no novo idioma
I18n.define(novoLocale);
// Persiste a preferência para a próxima abertura do aplicativo
final prefs = await SharedPreferences.getInstance();
final codigo = novoLocale.countryCode != null && novoLocale.countryCode!.isNotEmpty
? '${novoLocale.languageCode}_${novoLocale.countryCode}'
: novoLocale.languageCode;
await prefs.setString(_chaveIdioma, codigo);
// Notifica os widgets dependentes para se reconstruírem
notifyListeners();
}
// Retorna o nome legível de um locale
String nomeLegivel(Locale locale) {
switch (locale.languageCode) {
case 'pt': return 'Português (Brasil)';
case 'en': return 'English (US)';
case 'es': return 'Español';
default: return locale.toLanguageTag();
}
}
}import 'package:flutter/material.dart';
import 'package:i18n_extension/i18n_extension.dart';
import 'package:shared_preferences/shared_preferences.dart';
class PreferenciasIdiomaProvider extends ChangeNotifier {
static const _key = 'idioma_preferido';
Locale _locale = const Locale('pt', 'BR');
Locale get localeAtual => _locale;
static const idiomasDisponiveis = [
Locale('pt', 'BR'),
Locale('en', 'US'),
Locale('es'),
];
Future<void> carregarIdiomaSalvo() async {
final code = (await SharedPreferences.getInstance()).getString(_key);
if (code == null) return;
final p = code.split('_');
await alterarIdioma(p.length == 2 ? Locale(p[0], p[1]) : Locale(p[0]));
}
Future<void> alterarIdioma(Locale locale) async {
if (_locale == locale) return;
_locale = locale;
I18n.define(locale);
final code = locale.countryCode?.isNotEmpty == true
? '${locale.languageCode}_${locale.countryCode}'
: locale.languageCode;
await (await SharedPreferences.getInstance()).setString(_key, code);
notifyListeners();
}
String nomeLegivel(Locale l) => switch (l.languageCode) {
'pt' => 'Português (Brasil)',
'en' => 'English (US)',
'es' => 'Español',
_ => l.toLanguageTag(),
};
}Com esse provider configurado e registrado no MultiProvider da raiz do aplicativo, a tela de configurações pode exibir os idiomas disponíveis e chamar alterarIdioma quando o usuário faz uma escolha. Como o MaterialApp escuta o locale por meio de um Consumer<PreferenciasIdiomaProvider>, a troca é instantânea em toda a interface.
Seção 5 — Formatação de Datas, Horas, Números e Moedas com o Pacote intl
Traduzir textos é apenas metade do trabalho de internacionalização. A outra metade envolve formatar dados de acordo com as convenções locais. Um preço de R$ 29,90 não deve ser exibido como “29.90" para um usuário brasileiro, nem como "29,90 R” para um usuário americano. Uma data de 14 de março de 2025 deve aparecer como “14/03/2025” no Brasil e como “03/14/2025” nos Estados Unidos. O pacote intl cuida exatamente dessa formatação.
Formatação de datas e horas com DateFormat
A classe DateFormat do pacote intl permite formatar objetos DateTime em strings legíveis de acordo com um padrão específico ou de acordo com o locale configurado. Os padrões são compostos por letras que representam diferentes componentes da data:
| Padrão | Significado | Exemplo (pt-BR) | Exemplo (en-US) |
|---|---|---|---|
d |
Dia do mês (1–31) | 5 | 5 |
dd |
Dia com zero (01–31) | 05 | 05 |
M |
Mês (1–12) | 3 | 3 |
MM |
Mês com zero (01–12) | 03 | 03 |
MMM |
Mês abreviado | mar | Mar |
MMMM |
Mês por extenso | março | March |
y |
Ano (4 dígitos) | 2025 | 2025 |
H |
Hora 0–23 | 14 | 14 |
h |
Hora 1–12 | 2 | 2 |
mm |
Minuto 00–59 | 05 | 05 |
a |
AM/PM | PM | PM |
EEEE |
Dia da semana | sexta-feira | Friday |
import 'package:intl/intl.dart';
class FormataData {
/// Formata uma data no padrão curto do locale.
/// Ex: "14/03/2025" em pt-BR, "03/14/2025" em en-US.
static String dataAbreviada(DateTime data, String locale) {
final formatador = DateFormat.yMd(locale);
return formatador.format(data);
}
/// Formata uma data por extenso, com dia da semana.
/// Ex: "sexta-feira, 14 de março de 2025" em pt-BR.
static String dataExtensa(DateTime data, String locale) {
final formatador = DateFormat('EEEE, d \'de\' MMMM \'de\' y', locale);
return formatador.format(data);
}
/// Formata apenas a hora no formato HH:mm.
static String hora(DateTime data, String locale) {
final formatador = DateFormat.Hm(locale);
return formatador.format(data);
}
/// Formata data e hora juntos.
/// Ex: "14/03/2025, 14:35" em pt-BR.
static String dataEHora(DateTime data, String locale) {
final formatData = DateFormat.yMd(locale);
final formatHora = DateFormat.Hm(locale);
return '${formatData.format(data)}, ${formatHora.format(data)}';
}
}
// Exemplos de uso no aplicativo de delivery:
// Exibir data estimada de entrega
final entrega = DateTime(2025, 3, 14, 14, 35);
print(FormataData.dataAbreviada(entrega, 'pt_BR')); // "14/03/2025"
print(FormataData.dataAbreviada(entrega, 'en_US')); // "3/14/2025"
print(FormataData.hora(entrega, 'pt_BR')); // "14:35"
print(FormataData.hora(entrega, 'en_US')); // "14:35"import 'package:intl/intl.dart';
abstract final class FormataData {
static String dataAbreviada(DateTime d, String locale) =>
DateFormat.yMd(locale).format(d);
static String dataExtensa(DateTime d, String locale) =>
DateFormat('EEEE, d \'de\' MMMM \'de\' y', locale).format(d);
static String hora(DateTime d, String locale) =>
DateFormat.Hm(locale).format(d);
static String dataEHora(DateTime d, String locale) =>
'${DateFormat.yMd(locale).format(d)}, ${DateFormat.Hm(locale).format(d)}';
}Formatação de números e moedas com NumberFormat
O NumberFormat funciona de forma análoga ao DateFormat, mas para valores numéricos. Para o aplicativo de delivery, a formatação de preços é especialmente importante — um valor como 29.9 precisa aparecer como “R$ 29,90” para um usuário brasileiro e como “$29.90” para um usuário americano:
import 'package:intl/intl.dart';
class FormataValor {
/// Formata um valor como moeda de acordo com o locale.
/// O símbolo da moeda muda automaticamente com o locale.
static String moeda(double valor, String locale) {
// O NumberFormat.simpleCurrency detecta o símbolo correto para o locale.
// Para pt_BR: R$; para en_US: $; para es_ES: €
final formatador = NumberFormat.simpleCurrency(locale: locale);
return formatador.format(valor);
}
/// Formata um número com separador de milhar e casas decimais.
static String numero(double valor, String locale, {int decimais = 2}) {
final formatador = NumberFormat.decimalPatternDigits(
locale: locale,
decimalDigits: decimais,
);
return formatador.format(valor);
}
/// Formata um número como percentual.
static String percentual(double valor, String locale) {
final formatador = NumberFormat.percentPattern(locale);
return formatador.format(valor);
}
}
// Exemplos no contexto do delivery:
final preco = 29.9;
print(FormataValor.moeda(preco, 'pt_BR')); // "R$ 29,90"
print(FormataValor.moeda(preco, 'en_US')); // "$29.90"
final desconto = 0.15;
print(FormataValor.percentual(desconto, 'pt_BR')); // "15%"
print(FormataValor.percentual(desconto, 'en_US')); // "15%"
final frete = 8500.0;
print(FormataValor.numero(frete, 'pt_BR')); // "8.500,00"
print(FormataValor.numero(frete, 'en_US')); // "8,500.00"import 'package:intl/intl.dart';
abstract final class FormataValor {
static String moeda(double v, String locale) =>
NumberFormat.simpleCurrency(locale: locale).format(v);
static String numero(double v, String locale, {int decimais = 2}) =>
NumberFormat.decimalPatternDigits(locale: locale, decimalDigits: decimais)
.format(v);
static String percentual(double v, String locale) =>
NumberFormat.percentPattern(locale).format(v);
}Seção 6 — Suporte a Idiomas da Direita para a Esquerda (RTL)
Idiomas como árabe, hebraico, persa e urdu são escritos da direita para a esquerda — o oposto do que estamos acostumados com o português, inglês e espanhol. Quando um aplicativo suporta esses idiomas, toda a interface precisa ser espelhada: o texto começa no lado direito da tela, os ícones de “voltar” apontam para a direita, e os layouts que no LTR (left-to-right) colocam o ícone à esquerda do texto agora colocam o ícone à direita.
O Flutter lida com isso de forma notavelmente elegante. A maioria dos widgets do Flutter é “direction-aware”: eles observam a TextDirection do contexto e se adaptam automaticamente. Quando o locale do aplicativo é definido como árabe (Locale('ar')), o Flutter define automaticamente TextDirection.rtl como padrão para toda a interface, e os widgets Row, Column, Padding, e a maioria dos widgets de layout se espelham automaticamente.
Como o Flutter espelha a interface
O mecanismo de espelhamento do Flutter é baseado nas classes EdgeInsetsDirectional e AlignmentDirectional, que expressam posicionamento em termos de “início” e “fim” em vez de “esquerda” e “direita”. Por exemplo, EdgeInsetsDirectional.only(start: 16) adiciona 16 pixels à esquerda em LTR e à direita em RTL. Se você usar EdgeInsets.only(left: 16) diretamente, o espaço ficará sempre à esquerda, independentemente da direção do texto — o que quebra o layout em RTL.
A boa notícia é que, ao adicionar suporte ao árabe ou hebraico como locale, e ao usar os widgets e classes diretionalmente conscientes do Flutter (EdgeInsetsDirectional, AlignmentDirectional, TextDirection.rtl), o aplicativo se espelha automaticamente sem que você precise escrever lógica condicional para isso. As situações que requerem atenção especial são:
Ícones que têm direção semântica: um ícone de seta para a esquerda (Icons.arrow_back) deve se tornar uma seta para a direita em RTL. Para isso, use o widget Directionality ou verifique Directionality.of(context) para adaptar o ícone.
Imagens assimétricas que têm significado direcional: um gráfico de barras que cresce da esquerda para a direita deve crescer da direita para a esquerda em RTL. Nesses casos, você pode usar Transform.scale(scaleX: -1) para espelhar a imagem horizontalmente.
Textos hardcoded com caracteres de controle de direção: evite usar caracteres Unicode de controle de direção (como \u200e e \u200f) diretamente no código — deixe o Flutter resolver a direção com base no locale.
Seção 7 — Fundamentos de Acessibilidade
Acessibilidade é a prática de projetar produtos digitais de forma que pessoas com diferentes habilidades — visuais, auditivas, motoras ou cognitivas — possam utilizá-los com eficiência e independência. No contexto de aplicativos móveis, acessibilidade se traduz em: textos que podem ser ampliados sem quebrar o layout, cores com contraste suficiente para pessoas com baixa visão, interfaces que funcionam com o teclado físico sem depender de gestos complexos de toque, e suporte completo a leitores de tela.
Estima-se que cerca de 15% da população mundial vive com alguma forma de deficiência. No Brasil, segundo o Censo 2022 do IBGE, mais de 18 milhões de pessoas têm alguma deficiência visual, e outros milhões têm deficiências motoras, auditivas ou intelectuais. Construir interfaces acessíveis não é atender a uma minoria — é reconhecer que os usuários têm diferentes formas de interagir com a tecnologia.
As Diretrizes WCAG Aplicadas a Aplicativos Móveis
As WCAG (Web Content Accessibility Guidelines) são um conjunto de diretrizes desenvolvidas pelo W3C que estabelecem critérios objetivos para acessibilidade digital. Embora originalmente concebidas para a web, suas diretrizes se aplicam com grande pertinência aos aplicativos móveis. As WCAG são organizadas em torno de quatro princípios fundamentais, frequentemente lembrados pelo acrônimo POUR:
O princípio da Perceptibilidade (Perceivable) estabelece que toda informação apresentada na interface deve ser perceptível por todos os usuários, incluindo aqueles que não podem ver ou ouvir. Isso se traduz em oferecer alternativas textuais para conteúdo não textual (como imagens e ícones), garantir contraste suficiente entre texto e fundo, e não depender exclusivamente de cor para transmitir informação.
O princípio da Operabilidade (Operable) determina que todos os componentes da interface devem ser operáveis por diferentes meios de entrada. Em aplicativos móveis, isso significa que a interface deve funcionar corretamente quando o usuário usa um teclado físico (conectado via Bluetooth), um switch de acessibilidade, ou o próprio leitor de tela com gestos padrão. Alvos de toque muito pequenos violam esse princípio.
O princípio da Compreensibilidade (Understandable) exige que a interface e o conteúdo sejam compreensíveis — que o comportamento dos componentes seja previsível, que as mensagens de erro sejam claras e descritivas, e que formulários ofereçam instruções suficientes para serem preenchidos corretamente.
O princípio da Robustez (Robust) requer que o conteúdo seja robusto o suficiente para ser interpretado corretamente por tecnologias assistivas atuais e futuras. Em Flutter, isso se traduz em usar os widgets semânticos corretos e não depender de estruturas de layout que confundam os leitores de tela.
Tamanho mínimo de alvos de toque
Um dos critérios mais fáceis de implementar e mais frequentemente ignorados é o tamanho mínimo dos alvos de toque. As WCAG 2.5 recomendam um tamanho mínimo de 44×44 pontos para qualquer elemento interativo. O Material Design da Google é ainda mais conservador em suas diretrizes, recomendando 48×48 dp. Um ícone de 24×24 dp sem padding suficiente ao redor viola essa diretriz e é difícil de tocar com precisão, especialmente para usuários com tremor nas mãos.
Em Flutter, você garante esse tamanho mínimo usando widgets que já respeitam essa diretriz por padrão, como ElevatedButton, IconButton e FloatingActionButton. Quando cria interatividade customizada com GestureDetector, você precisa garantir explicitamente que a área clicável tenha tamanho suficiente:
graph TD
A[Elemento interativo] --> B{Tem 44x44 dp?}
B -->|Sim| C[Acessível para uso motor]
B -->|Não| D[Adicionar padding\nou usar widget maior]
D --> C
Contraste de cores
O contraste entre cor do texto e cor do fundo é um dos critérios mais verificáveis da WCAG. O nível AA (o mínimo recomendado) exige uma razão de contraste de pelo menos 4,5:1 para texto normal e 3:1 para texto grande (acima de 18pt ou 14pt em negrito). O nível AAA exige 7:1 para texto normal.
A razão de contraste é calculada com base na luminância relativa das duas cores. A fórmula formal é:
CR = \frac{L_1 + 0{,}05}{L_2 + 0{,}05}
onde L_1 é a luminância relativa da cor mais clara e L_2 é a luminância relativa da cor mais escura, e a luminância relativa L de uma cor no espaço sRGB é calculada como:
L = 0{,}2126 \cdot R_{lin} + 0{,}7152 \cdot G_{lin} + 0{,}0722 \cdot B_{lin}
onde cada componente linear C_{lin} é calculado a partir do valor de 8 bits C_{sRGB} (normalizado entre 0 e 1) como:
C_{lin} = \begin{cases} \dfrac{C_{sRGB}}{12{,}92} & \text{se } C_{sRGB} \leq 0{,}04045 \\[8pt] \left(\dfrac{C_{sRGB} + 0{,}055}{1{,}055}\right)^{2{,}4} & \text{se } C_{sRGB} > 0{,}04045 \end{cases}
Na prática, você não precisará calcular isso manualmente — ferramentas como o plugin “Colour Contrast Checker” do Figma ou o site WebAIM Contrast Checker fazem esse cálculo imediatamente. O importante é que você saiba que contraste não é uma questão de preferência estética, mas de conformidade com uma fórmula matemática objetiva.
Seção 8 — O Widget Semantics
O TalkBack (Android) e o VoiceOver (iOS) são leitores de tela que verbalizam os elementos da interface para usuários com deficiência visual. Quando um usuário com TalkBack ativado toca em um elemento da tela, o leitor de tela anuncia o que aquele elemento é e o que ele faz. Por exemplo, ao tocar no ícone de carrinho de compras, o TalkBack deveria anunciar “Carrinho de compras, 3 itens, botão”. Se o ícone não tiver informação semântica associada, o TalkBack anuncia apenas “sem rótulo” — o que é completamente inútil para o usuário.
O widget Semantics é o mecanismo do Flutter para adicionar essas informações descritivas aos elementos da interface. Ele cria um nó na árvore de semântica — uma representação paralela e simplificada da árvore de widgets, otimizada para ser consumida por tecnologias assistivas. A árvore de semântica contém apenas os elementos relevantes para acessibilidade, com suas propriedades descritivas: rótulos, valores, dicas, estados e ações.
Como o Flutter constrói a árvore de semântica
Antes de você adicionar qualquer Semantics explicitamente, é importante entender que muitos widgets do Flutter já têm semântica embutida. O Text anuncia seu conteúdo textual. O ElevatedButton anuncia seu rótulo e o fato de ser um botão. O Checkbox anuncia seu estado (marcado ou desmarcado). O TextField anuncia seu rótulo e o texto atual.
O problema surge com widgets que não têm texto visível associado: ícones, imagens, indicadores de progresso, e widgets customizados construídos com GestureDetector. Nesses casos, o Semantics preenche a lacuna.
graph TD
A[Árvore de Widgets Flutter] -->|Flutter Engine| B[Árvore de Semântica]
B -->|Android| C[AccessibilityNodeInfo\nTalkBack]
B -->|iOS| D[UIAccessibility\nVoiceOver]
style A fill:#e3f2fd
style B fill:#fff9c4
style C fill:#e8f5e9
style D fill:#fce4ec
Propriedades principais do widget Semantics
O widget Semantics expõe diversas propriedades que mapeiam diretamente para as APIs de acessibilidade nativas de cada plataforma:
A propriedade label é a descrição textual do elemento — o equivalente ao atributo alt das imagens na web. É o texto que o leitor de tela vai anunciar quando o usuário navegar até o elemento.
A propriedade hint fornece uma dica sobre o que acontece quando o usuário interage com o elemento — por exemplo, “abre o menu de opções do pedido”. O TalkBack anuncia a dica após uma pausa, geralmente quando o usuário deixa o dedo pousado sobre o elemento por mais tempo.
A propriedade value descreve o valor atual de um widget que tem estado variável. Em um slider de quantidade, por exemplo, o valor seria “3” quando a quantidade selecionada é 3.
A propriedade button indica que o elemento se comporta como um botão — mesmo que visualmente seja apenas um container ou uma imagem clicável.
A propriedade enabled indica se o elemento está habilitado para interação. Elementos desabilitados são anunciados como tal pelo leitor de tela.
A propriedade liveRegion indica que o conteúdo do elemento pode mudar dinamicamente e que o leitor de tela deve anunciar as mudanças automaticamente, sem que o usuário precise navegar até o elemento. Isso é útil para mensagens de status que aparecem após ações.
import 'package:flutter/material.dart';
class BotaoAdicionarAoCarrinho extends StatelessWidget {
final String nomeProduto;
final double precoProduto;
final VoidCallback aoAdicionar;
const BotaoAdicionarAoCarrinho({
super.key,
required this.nomeProduto,
required this.precoProduto,
required this.aoAdicionar,
});
@override
Widget build(BuildContext context) {
return Semantics(
// Descrição completa e contextual do botão.
// O TalkBack vai anunciar: "Adicionar Pizza Margherita ao carrinho por R$ 39,90, botão"
label: 'Adicionar $nomeProduto ao carrinho por R\$ ${precoProduto.toStringAsFixed(2)}',
// Indica que este elemento se comporta como botão
button: true,
// Dica de interação (anunciada com pausa ou ao pressionar longa)
hint: 'Toque duas vezes para adicionar ao carrinho',
// O child é o widget visual — separado da semântica
child: GestureDetector(
onTap: aoAdicionar,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// O ícone é decorativo — ExcludeSemantics
// evita que ele seja anunciado separadamente
ExcludeSemantics(
child: Icon(Icons.add_shopping_cart, color: Colors.white),
),
const SizedBox(width: 8),
// O texto do botão é redundante com o label semântico,
// então excluímos da semântica para evitar repetição
ExcludeSemantics(
child: Text(
'Adicionar',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
],
),
),
),
);
}
}import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class BotaoAdicionarAoCarrinho extends StatelessWidget {
final String nomeProduto;
final double precoProduto;
final VoidCallback aoAdicionar;
const BotaoAdicionarAoCarrinho({
super.key,
required this.nomeProduto,
required this.precoProduto,
required this.aoAdicionar,
});
@override
Widget build(BuildContext context) {
final preco = NumberFormat.simpleCurrency(locale: 'pt_BR').format(precoProduto);
return Semantics(
label: 'Adicionar $nomeProduto ao carrinho por $preco',
button: true,
hint: 'Toque duas vezes para adicionar ao carrinho',
child: GestureDetector(
onTap: aoAdicionar,
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
ExcludeSemantics(child: Icon(Icons.add_shopping_cart, color: Colors.white)),
SizedBox(width: 8),
ExcludeSemantics(
child: Text('Adicionar', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
),
],
),
),
),
),
);
}
}ExcludeSemantics e MergeSemantics
Dois widgets complementares ao Semantics merecem atenção especial. O ExcludeSemantics remove completamente um subtree da árvore de semântica — o que você deve usar para elementos puramente decorativos, como ícones que já têm seu significado descrito pelo rótulo do elemento pai, ou separadores visuais sem significado informativo.
O MergeSemantics combina as informações semânticas de todos os seus descendentes em um único nó na árvore de semântica. Isso é útil quando você tem um card que contém múltiplos elementos de texto e ícones, mas que, para o leitor de tela, deve ser tratado como uma única entidade interativa. Sem o MergeSemantics, o TalkBack navegaria por cada filho individualmente; com ele, o card inteiro é anunciado de uma vez:
// Card de produto que deve ser tratado como um único elemento pelo TalkBack
class CardProduto extends StatelessWidget {
final String nome;
final String descricao;
final double preco;
final VoidCallback aoTocar;
const CardProduto({
super.key,
required this.nome,
required this.descricao,
required this.preco,
required this.aoTocar,
});
@override
Widget build(BuildContext context) {
// MergeSemantics une todos os textos filhos em um único anúncio.
// O TalkBack vai anunciar: "Pizza Margherita, Molho de tomate, mozzarella,
// manjericão, R$ 39,90, botão"
return MergeSemantics(
child: GestureDetector(
onTap: aoTocar,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Imagem decorativa — excluída da semântica
ExcludeSemantics(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
'url_da_imagem',
width: 80,
height: 80,
fit: BoxFit.cover,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nome,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
Text(
descricao,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey),
),
Text(
'R\$ ${preco.toStringAsFixed(2)}',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
),
),
);
}
}import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class CardProduto extends StatelessWidget {
final String nome, descricao;
final double preco;
final VoidCallback aoTocar;
const CardProduto({
super.key,
required this.nome,
required this.descricao,
required this.preco,
required this.aoTocar,
});
@override
Widget build(BuildContext context) => MergeSemantics(
child: GestureDetector(
onTap: aoTocar,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
ExcludeSemantics(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network('url', width: 80, height: 80, fit: BoxFit.cover),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(nome, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
Text(descricao, maxLines: 2, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.grey)),
Text(NumberFormat.simpleCurrency(locale: 'pt_BR').format(preco),
style: TextStyle(color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold)),
],
),
),
],
),
),
),
),
);
}Semântica em estados dinâmicos: liveRegion
Quando o aplicativo exibe mensagens de status que mudam dinamicamente — como “Pedido adicionado ao carrinho” ou “Erro: endereço não encontrado” — o usuário com deficiência visual precisa ser informado dessa mudança sem precisar navegar manualmente até o elemento. A propriedade liveRegion: true do Semantics instrui o leitor de tela a anunciar automaticamente qualquer mudança no conteúdo daquele nó:
// Widget que exibe mensagens de status dinamicamente
class MensagemStatus extends StatelessWidget {
final String? mensagem;
const MensagemStatus({super.key, this.mensagem});
@override
Widget build(BuildContext context) {
if (mensagem == null) return const SizedBox.shrink();
return Semantics(
// liveRegion instrui o TalkBack a anunciar esta mensagem
// automaticamente assim que ela aparecer na tela,
// sem que o usuário precise navegar até ela.
liveRegion: true,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green),
),
child: Text(mensagem!),
),
);
}
}import 'package:flutter/material.dart';
class MensagemStatus extends StatelessWidget {
final String? mensagem;
const MensagemStatus({super.key, this.mensagem});
@override
Widget build(BuildContext context) {
if (mensagem == null) return const SizedBox.shrink();
return Semantics(
liveRegion: true,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green),
),
child: Text(mensagem!),
),
);
}
}Seção 9 — Testando a Acessibilidade com TalkBack e VoiceOver
Implementar semântica é necessário, mas não suficiente. Você precisa verificar que a experiência auditiva com o TalkBack ou o VoiceOver é de fato compreensível e eficiente. Essa verificação requer que você use o aplicativo da forma como um usuário com deficiência visual o utilizaria — o que é uma experiência bastante reveladora.
Habilitando o TalkBack no Android
Para habilitar o TalkBack no emulador ou em um dispositivo físico Android, vá até Configurações > Acessibilidade > TalkBack e ative-o. Após a ativação, a forma de interagir com o dispositivo muda completamente:
Com o TalkBack ativo, um único toque move o foco para o elemento sob o dedo, e o TalkBack o anuncia em voz alta. Para ativar (tocar em) o elemento focado, você precisa fazer um toque duplo. Para navegar entre elementos, você pode deslizar para a direita (próximo elemento) ou para a esquerda (elemento anterior). Para rolar uma lista, você usa dois dedos ao mesmo tempo.
Usar o TalkBack pela primeira vez pode ser frustrante — você não está acostumado com essa forma de interação. Mas é exatamente essa frustração que te ajuda a ter empatia pelo usuário que depende dessa tecnologia diariamente. Reserve 15 minutos para navegar pelo aplicativo somente com o TalkBack ativo, sem olhar para a tela, e anote tudo que não ficou claro ou que não foi anunciado corretamente.
Verificando a árvore de semântica no Flutter
Além de testar manualmente com o TalkBack, o Flutter oferece uma ferramenta programática para visualizar a árvore de semântica gerada: o método debugDumpSemanticsTree(), que imprime no console uma representação textual de todos os nós semânticos da interface atual. Para usá-lo, adicione uma chamada em algum ponto conveniente do código durante o desenvolvimento:
O Flutter Inspector no VS Code e no Android Studio também tem um painel de semântica que exibe a árvore de semântica visualmente, lado a lado com a árvore de widgets. Acesse-o pelo menu Flutter Inspector > Semantics.
O que verificar durante os testes
Ao navegar pelo aplicativo com o TalkBack, verifique especificamente os seguintes aspectos:
Todos os elementos interativos recebem foco? Se você não consegue alcançar um botão ou link navegando com os gestos do TalkBack, ele está invisível para o usuário com deficiência visual.
O anúncio é descritivo e contextual? “Botão” é insuficiente; “Adicionar Pizza Margherita ao carrinho, botão” é informativo. “Imagem” é insuficiente; “Foto do prato Pizza Margherita com queijo derretido e manjericão fresco” é descritivo.
A ordem de navegação faz sentido? O TalkBack navega pelos elementos na ordem em que eles aparecem na árvore de semântica, que geralmente corresponde à ordem de declaração no código. Layouts complexos com Stack e posicionamento absoluto podem gerar ordens de navegação confusas.
As mudanças de estado são anunciadas? Quando o usuário adiciona um produto ao carrinho, o contador de itens no ícone do carrinho muda — essa mudança deve ser anunciada pela liveRegion correspondente.
Seção 10 — Adaptação ao Tamanho de Fonte e Alto Contraste
Além do suporte a leitores de tela, há outras duas dimensões de acessibilidade que um aplicativo profissional precisa respeitar: o tamanho de fonte configurado pelo usuário no sistema operacional e o modo de alto contraste. Esses dois ajustes são amplamente utilizados por pessoas com baixa visão que ainda conseguem ver a tela, mas precisam de texto maior ou de cores com mais contraste.
Respeitando o textScaleFactor do sistema
No Android, o usuário pode ir até Configurações > Acessibilidade > Tamanho da fonte e aumentar o tamanho do texto do sistema. O Flutter expõe esse fator de escala por meio de MediaQuery.of(context).textScaleFactor. Por padrão, o Flutter aplica esse fator automaticamente a todos os widgets Text da interface — o que significa que um texto definido com fontSize: 16 será renderizado com tamanho 24 quando o textScaleFactor for 1.5.
Esse comportamento é positivo: seu texto já se adapta automaticamente. Mas você precisa garantir que seu layout também se adapta. Um cartão com altura fixa de 80 pixels que funcionava bem com texto de 16pt pode quebrar quando o texto aumenta para 24pt. O segredo está em evitar alturas fixas para containers que contêm texto, preferindo usar IntrinsicHeight, Flexible, ou simplesmente não definir height quando não é necessário.
Há situações em que você pode querer limitar o textScaleFactor para elementos específicos que têm restrições físicas de espaço — como etiquetas dentro de ícones de navegação inferior. O Flutter 3 introduziu o TextScaler como substituto mais robusto ao textScaleFactor. Para aplicar um limite máximo de escala em um widget específico:
// Limitar o textScaleFactor para um elemento específico.
// Use com muito cuidado: em geral, é preferível adaptar o layout
// em vez de limitar a escala.
Widget buildRotuloNavegacao(BuildContext context, String texto) {
// Obtém o textScaleFactor configurado pelo usuário
final fatorOriginal = MediaQuery.of(context).textScaler;
// Cria um fator limitado: no máximo 1.3x o tamanho original
final fatorLimitado = fatorOriginal.clamp(
minScaleFactor: 1.0,
maxScaleFactor: 1.3,
);
return MediaQuery(
// Sobrescreve o textScaler apenas para os filhos deste MediaQuery
data: MediaQuery.of(context).copyWith(textScaler: fatorLimitado),
child: Text(
texto,
style: const TextStyle(fontSize: 12),
overflow: TextOverflow.ellipsis,
),
);
}Widget buildRotuloNavegacao(BuildContext context, String texto) => MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: MediaQuery.of(context).textScaler.clamp(
minScaleFactor: 1.0,
maxScaleFactor: 1.3,
),
),
child: Text(texto, style: const TextStyle(fontSize: 12), overflow: TextOverflow.ellipsis),
);Modo de alto contraste
O Android e o iOS oferecem modos de alto contraste para usuários com baixa visão que precisam de fronteiras mais definidas entre elementos e cores com maior diferença de luminância. O Flutter expõe essa configuração por meio de MediaQuery.of(context).highContrast.
O ideal é que o design do seu aplicativo já tenha contraste suficiente para dispensar um modo especial — mas se o design tem elementos decorativos de baixo contraste ou utiliza tons de cinza muito próximos, você pode detectar o modo de alto contraste e ajustar o tema:
// lib/core/tema/tema_app.dart
import 'package:flutter/material.dart';
class TemaApp {
/// Retorna o tema adequado com base nas configurações de acessibilidade do usuário.
static ThemeData obterTema(BuildContext context) {
// Detecta se o usuário ativou o modo de alto contraste no sistema
final altoContraste = MediaQuery.of(context).highContrast;
if (altoContraste) {
// Tema de alto contraste: fundo branco puro, texto preto puro,
// bordas bem definidas, cores primárias com alta saturação
return ThemeData(
colorScheme: const ColorScheme.light(
primary: Colors.black,
onPrimary: Colors.white,
secondary: Colors.black,
onSecondary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
error: Color(0xFFCC0000), // vermelho escuro de alto contraste
),
cardTheme: CardTheme(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: const BorderSide(color: Colors.black, width: 2),
),
),
);
}
// Tema padrão da aplicação
return ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFE53935), // vermelho do delivery
),
);
}
}import 'package:flutter/material.dart';
abstract final class TemaApp {
static ThemeData obterTema(BuildContext context) =>
MediaQuery.of(context).highContrast ? _altoContraste : _padrao;
static final _padrao = ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFE53935)),
);
static final _altoContraste = ThemeData(
colorScheme: const ColorScheme.light(
primary: Colors.black,
onPrimary: Colors.white,
secondary: Colors.black,
onSecondary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
error: Color(0xFFCC0000),
),
cardTheme: CardTheme(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: const BorderSide(color: Colors.black, width: 2),
),
),
);
}Seção 11 — Integrando Tudo no Aplicativo de Delivery
Até aqui, você aprendeu cada técnica de forma isolada. Nesta seção, você verá como elas se integram no contexto real do aplicativo de delivery. A internalização e a acessibilidade não são camadas adicionadas por cima da aplicação — elas são propriedades que permeiam cada widget, cada string e cada decisão de layout que você toma.
Mapa conceitual da internacionalização e acessibilidade
mindmap
root((App Delivery))
Internacionalização
i18n_extension
operador .i18n
método .fill
método .plural
intl
DateFormat
NumberFormat
Locale
Detecção automática
Seletor manual
SharedPreferences
RTL
EdgeInsetsDirectional
TextDirection
Acessibilidade
Semântica
Semantics label
Semantics hint
Semantics value
liveRegion
ExcludeSemantics
MergeSemantics
Contraste
WCAG AA 4.5:1
WCAG AAA 7:1
Alto contraste
Escala de fonte
textScaleFactor
TextScaler.clamp
Tamanho de toque
Mínimo 44x44 dp
Leitores de tela
TalkBack Android
VoiceOver iOS
Checklist de acessibilidade e internacionalização
Antes de considerar o aplicativo pronto para o Projeto Integrador, verifique cada item desta lista:
Internacionalização
- Todos os textos visíveis ao usuário usam o operador
.i18n? - As traduções para todos os idiomas suportados estão completas?
- Os preços são formatados com
NumberFormat.simpleCurrencyde acordo com o locale? - As datas são formatadas com
DateFormatde acordo com o locale? - A preferência de idioma é salva e restaurada entre sessões?
- O
localeResolutionCallbackdetecta corretamente o idioma do sistema?
Acessibilidade
- Todos os ícones interativos têm rótulo semântico (
Semantics.label)? - Imagens decorativas estão excluídas da semântica (
ExcludeSemantics)? - Os cards clicáveis usam
MergeSemanticspara evitar fragmentação da navegação? - Mensagens de status dinâmicas usam
liveRegion: true? - O layout não quebra quando o
textScaleFactoré 1.5 ou 2.0? - O contraste entre texto e fundo é de pelo menos 4,5:1?
- O aplicativo funciona corretamente com o TalkBack ativado?
- O modo de alto contraste é detectado e tratado?
Um exemplo completo: a tela de detalhes do pedido acessível e internacionalizada
A tela de detalhes do pedido é uma das mais densas em informação no aplicativo de delivery. Ela exibe o número do pedido, a data, os itens, os preços e o status atual. Veja como todos os conceitos deste módulo se aplicam nela:
// lib/features/pedidos/presentation/screens/tela_detalhe_pedido.dart
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:delivery/l10n/traducoes.dart';
class TelaDetalhePedido extends StatelessWidget {
final Pedido pedido;
final String locale;
const TelaDetalhePedido({
super.key,
required this.pedido,
required this.locale,
});
@override
Widget build(BuildContext context) {
final formatData = DateFormat.yMMMd(locale);
final formatHora = DateFormat.Hm(locale);
final formatMoeda = NumberFormat.simpleCurrency(locale: locale);
return Scaffold(
appBar: AppBar(
// Título internacionalizado com interpolação do número do pedido
title: Text('Pedido #%s'.i18n.fill([pedido.numero])),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Card de status com semântica adequada para o TalkBack
Semantics(
// O label descreve completamente o status para o TalkBack
label: '${'Status'.i18n}: ${pedido.status.i18n}',
// liveRegion anuncia mudanças de status automaticamente
liveRegion: true,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ExcludeSemantics porque o label pai já descreve o status
ExcludeSemantics(
child: Text(
'Status'.i18n,
style: Theme.of(context).textTheme.labelSmall,
),
),
ExcludeSemantics(
child: Text(
pedido.status.i18n,
style: Theme.of(context).textTheme.titleLarge,
),
),
],
),
),
),
),
const SizedBox(height: 8),
// Data e hora do pedido com formato internacionalizado
Semantics(
label: '${'Data do pedido'.i18n}: '
'${formatData.format(pedido.data)} '
'${'às'.i18n} '
'${formatHora.format(pedido.data)}',
child: ListTile(
// O ícone é decorativo — excluímos da semântica
leading: ExcludeSemantics(
child: const Icon(Icons.calendar_today),
),
// O título e subtítulo estão cobertos pelo label semântico pai
title: ExcludeSemantics(
child: Text(formatData.format(pedido.data)),
),
subtitle: ExcludeSemantics(
child: Text(formatHora.format(pedido.data)),
),
),
),
const Divider(),
// Lista de itens do pedido
Text(
'Itens do pedido'.i18n,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...pedido.itens.map((item) {
// MergeSemantics une nome, quantidade e preço em um único anúncio
return MergeSemantics(
child: ListTile(
title: Text(item.nomeProduto),
subtitle: Text(
// Pluralização para "1 unidade" vs "2 unidades"
'1 unidade'.plural(item.quantidade),
),
trailing: Text(
formatMoeda.format(item.precoTotal),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}),
const Divider(),
// Total do pedido
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Semantics(
label: '${'Total'.i18n}: ${formatMoeda.format(pedido.total)}',
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ExcludeSemantics(child: Text('Total'.i18n,
style: Theme.of(context).textTheme.titleLarge)),
ExcludeSemantics(child: Text(
formatMoeda.format(pedido.total),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
)),
],
),
),
),
],
),
);
}
}import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:delivery/l10n/traducoes.dart';
class TelaDetalhePedido extends StatelessWidget {
final Pedido pedido;
final String locale;
const TelaDetalhePedido({super.key, required this.pedido, required this.locale});
@override
Widget build(BuildContext context) {
final fmtData = DateFormat.yMMMd(locale);
final fmtHora = DateFormat.Hm(locale);
final fmtMoeda = NumberFormat.simpleCurrency(locale: locale);
return Scaffold(
appBar: AppBar(title: Text('Pedido #%s'.i18n.fill([pedido.numero]))),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Semantics(
label: '${'Status'.i18n}: ${pedido.status.i18n}',
liveRegion: true,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ExcludeSemantics(child: Text('Status'.i18n, style: Theme.of(context).textTheme.labelSmall)),
ExcludeSemantics(child: Text(pedido.status.i18n, style: Theme.of(context).textTheme.titleLarge)),
],
),
),
),
),
const SizedBox(height: 8),
Semantics(
label: '${'Data do pedido'.i18n}: ${fmtData.format(pedido.data)} ${'às'.i18n} ${fmtHora.format(pedido.data)}',
child: ListTile(
leading: ExcludeSemantics(child: const Icon(Icons.calendar_today)),
title: ExcludeSemantics(child: Text(fmtData.format(pedido.data))),
subtitle: ExcludeSemantics(child: Text(fmtHora.format(pedido.data))),
),
),
const Divider(),
Text('Itens do pedido'.i18n, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...pedido.itens.map((item) => MergeSemantics(
child: ListTile(
title: Text(item.nomeProduto),
subtitle: Text('1 unidade'.plural(item.quantidade)),
trailing: Text(fmtMoeda.format(item.precoTotal),
style: TextStyle(fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary)),
),
)),
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Semantics(
label: '${'Total'.i18n}: ${fmtMoeda.format(pedido.total)}',
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ExcludeSemantics(child: Text('Total'.i18n, style: Theme.of(context).textTheme.titleLarge)),
ExcludeSemantics(child: Text(fmtMoeda.format(pedido.total),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
))),
],
),
),
),
],
),
);
}
}Seção 12 — Reflexões sobre Inclusão Digital e Responsabilidade do Desenvolvedor
Ao longo deste módulo, você aprendeu as ferramentas técnicas para internacionalizar e tornar acessível um aplicativo Flutter. Mas vale a pena fechar com uma reflexão mais ampla sobre o que essas práticas significam no contexto da sua formação como engenheiro de software.
Internacionalização e acessibilidade têm em comum o fato de serem investimentos que ampliam o alcance do que você constrói. Um aplicativo que fala apenas português atinge uma fatia da população mundial. Um que fala inglês e espanhol também atinge outras fatias enormes. E um que funciona bem com o TalkBack inclui dezenas de milhões de pessoas no Brasil que vivem com deficiências visuais e que, sem a sua atenção a esses detalhes, simplesmente não conseguiriam usar o que você criou.
A noção de que acessibilidade é uma preocupação de “nicho” está completamente equivocada. Considere que, além das pessoas com deficiências permanentes, existe um universo imenso de situações temporárias ou situacionais: alguém usando o celular ao sol forte, que precisa de alto contraste para enxergar a tela; alguém com um braço imobilizado, que não consegue usar gestos com dois dedos; um idoso que aumentou o tamanho da fonte porque suas lentes bifocais não estão funcionando bem naquele dia. Cada uma dessas situações é atendida pelas mesmas práticas de acessibilidade que você aprendeu aqui.
Há também a dimensão da responsabilidade legal. No Brasil, o Modelo de Acessibilidade em Governo Eletrônico (eMAG) e a Lei Brasileira de Inclusão (LBI) estabelecem que os serviços digitais prestados a cidadãos brasileiros devem ser acessíveis. Nos Estados Unidos, a Americans with Disabilities Act (ADA) tem sido aplicada a aplicativos móveis por tribunais em crescente número de processos. Na Europa, o European Accessibility Act exige acessibilidade digital a partir de 2025. Ignorar acessibilidade é, em muitos contextos, ignorar a lei.
Por fim, construir com acessibilidade em mente torna seu código melhor em geral. A obrigação de dar nomes descritivos a elementos da interface força você a pensar sobre o propósito de cada componente. A necessidade de garantir que o layout não quebre com fontes maiores leva a layouts mais robustos. A exigência de alto contraste geralmente resulta em designs mais limpos e legíveis para todos.
A acessibilidade não é o oposto da estética — é a evidência de que você se preocupa com quem vai usar o que você criou.
Seção 13 — Boas Práticas e Armadilhas Comuns
Esta seção reúne lições aprendidas na prática — coisas que desenvolvedores frequentemente fazem de forma incorreta quando internacionalizam ou tornam acessíveis seus aplicativos pela primeira vez.
Armadilhas na internacionalização
Não traduzir textos em placeholders e labels de formulário. O TextField tem propriedades hintText (texto de exemplo dentro do campo) e labelText (rótulo flutuante). Ambas precisam ser traduzidas com .i18n, assim como qualquer texto dentro de botões, menus e mensagens de erro. É comum esquecer os textos que parecem “internos” mas são visíveis ao usuário.
Codificar formatos de data diretamente como strings. Escrever '${data.day}/${data.month}/${data.year}' no código produz um formato fixo que não respeita o locale. Use sempre o DateFormat do pacote intl.
Ignorar o impacto do comprimento variável dos textos traduzidos no layout. O alemão, em particular, produz traduções consideravelmente mais longas que o português ou o inglês. Botões com SizedBox de largura fixa podem não acomodar o texto alemão. Prefira tamanhos flexíveis e use overflow: TextOverflow.ellipsis onde necessário.
Esquecer de chamar I18n.define() ao inicializar o idioma. O i18n_extension precisa ser notificado sobre o locale atual antes que os operadores .i18n possam retornar as traduções corretas. Se você esquecer dessa chamada na inicialização do aplicativo, todos os textos serão exibidos no idioma padrão, independentemente do locale configurado.
Armadilhas na acessibilidade
Adicionar Semantics sem testar com o TalkBack. Você pode escrever rótulos semânticos que parecem corretos no código mas que soam estranhos ou redundantes quando anunciados pelo leitor de tela. Sempre teste na prática.
Usar ExcludeSemantics em excesso. Excluir elementos da semântica quando eles contêm informação relevante priva o usuário de TalkBack de acesso a esse conteúdo. Use ExcludeSemantics somente para elementos verdadeiramente decorativos.
Não testar com textScaleFactor maior que 1. Um layout que funciona com fontes padrão pode quebrar completamente com textScaleFactor: 2.0. Configure o emulador para usar o tamanho de fonte máximo e navegue por todas as telas do aplicativo.
Criar alvos de toque menores que 44×44 dp. Ícones de 24 dp sem padding adequado ao redor são difíceis de tocar para todos, e praticamente impossíveis para usuários com tremor ou com motricidade fina reduzida. O widget IconButton do Flutter já aplica o padding adequado por padrão; use-o em vez de envolver ícones com GestureDetector sem padding.
Transmitir informação apenas por cor. Usar exclusivamente cor para indicar que um campo de formulário tem erro (colorindo a borda de vermelho) falha para usuários com daltonismo. Combine a cor com um ícone e uma mensagem textual descritiva.
Resumo do Módulo 14
Neste módulo, você percorreu um caminho que transforma o modo como você pensa sobre o desenvolvimento de aplicativos. Você aprendeu que internacionalização não é traduzir textos — é preparar a arquitetura do aplicativo para que a tradução, a formatação regional e a adaptação cultural possam acontecer de forma sistemática e escalável. E aprendeu que acessibilidade não é uma feature adicional — é a qualidade de um software que respeita a diversidade das pessoas que o utilizam.
Do lado técnico, você dominou o i18n_extension com o operador .i18n, a interpolação com .fill() e a pluralização com .plural(). Você aprendeu a formatar datas com DateFormat e preços com NumberFormat.simpleCurrency(), respeitando as convenções de cada locale. Você entendeu como o Flutter lida automaticamente com idiomas RTL quando configurado corretamente. Você aprendeu a usar o widget Semantics e seus complementares ExcludeSemantics e MergeSemantics para construir uma árvore de semântica que o TalkBack e o VoiceOver possam anunciar de forma útil. E você aprendeu a detectar e responder ao textScaleFactor e ao modo de alto contraste configurados pelo usuário no sistema operacional.
Estas competências distinguem desenvolvedores que constroem software funcional de desenvolvedores que constroem software inclusivo. E software inclusivo tem mais usuários, mais impacto e mais valor.