Módulo 14 — Exercícios: Internacionalização e Acessibilidade
Chegamos ao módulo que expande o alcance do seu aplicativo de delivery para além das fronteiras de um único idioma e de um único perfil de usuário. Até aqui, você construiu um sistema funcional, seguro e bem estruturado. Mas funcional para quem? Um aplicativo que fala apenas português não serve a um turista americano que quer fazer um pedido. Um aplicativo sem semântica adequada é invisível para um usuário com deficiência visual que depende do TalkBack para navegar pelo celular. Este módulo trata exatamente dessas duas dimensões: a internacionalização, que prepara o código para suportar múltiplos idiomas de forma sistemática, e a acessibilidade, que garante que pessoas com diferentes habilidades possam usar o que você construiu.
Os três exercícios a seguir percorrem esse caminho de forma progressiva e conectada. O primeiro estabelece a fundação: configurar o i18n_extension, criar as traduções para português e inglês, e implementar a troca de idioma em tempo de execução por meio de um Provider. O segundo aprofunda a acessibilidade: você vai construir os componentes centrais do aplicativo de delivery — cartão de produto, botão de adicionar ao carrinho e indicador de status do pedido — de forma que o TalkBack possa anunciá-los com precisão e utilidade. O terceiro fecha o ciclo com os aspectos mais avançados de ambas as dimensões: pluralização com contexto gramatical, interpolação de variáveis nas traduções, formatação de moeda e data de acordo com o locale ativo, gerenciamento de foco com FocusNode, anúncios programáticos com SemanticsService.announce, e adaptação do layout ao TextScaler configurado pelo usuário no sistema operacional.
Leia cada enunciado com atenção antes de escrever qualquer linha de código. As decisões de design que você toma aqui têm consequências diretas na experiência de usuários que talvez nunca consiga testar pessoalmente.
Exercício 1
Fundação da Internacionalização: i18n_extension, PreferenciasIdiomaProvider e Seletor de Idioma
Antes de traduzir textos, é preciso construir a infraestrutura que os sustenta
Toda decisão de internacionalização começa antes do primeiro .i18n que você escreve. Ela começa na configuração do MaterialApp, na estrutura de arquivos de tradução, no ChangeNotifier que mantém o locale ativo e nas camadas de persistência que garantem que a preferência do usuário sobreviva ao fechamento do aplicativo. Se qualquer dessas peças estiver mal encaixada, o resultado é um aplicativo que parece internacionalizado mas que exibe os textos errados, ignora a preferência salva ou exige reinicialização para aplicar a troca de idioma. Este exercício constrói essa fundação de forma correta e completa.
O contexto é o seguinte: o aplicativo de delivery foi aprovado para expansão para o mercado americano. A gerência decidiu que o aplicativo deve suportar português do Brasil e inglês americano, com a possibilidade de adicionar outros idiomas no futuro sem modificar o código central. O usuário deve poder escolher o idioma dentro da própria tela de configurações do aplicativo, e essa escolha deve ser lembrada para a próxima abertura, sem que o aplicativo precise ser reiniciado.
O primeiro componente que você deve implementar é a configuração do MaterialApp. Ele precisa declarar os delegates de localização do Flutter (GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, GlobalWidgetsLocalizations.delegate) e implementar o localeResolutionCallback para que, quando o idioma do sistema operacional for português (qualquer variante), o aplicativo use Locale('pt', 'BR'), e quando for inglês, use Locale('en', 'US'). Para qualquer outro idioma, o fallback deve ser o português. O locale do MaterialApp não deve ser fixo: ele deve ser lido de um PreferenciasIdiomaProvider registrado no MultiProvider da raiz da aplicação, de forma que a tela do MaterialApp seja reconstruída automaticamente quando o usuário trocar o idioma. Para que o i18n_extension acompanhe essa mudança, é necessário que, ao definir o novo locale, você chame I18n.define(novoLocale) antes de notificar os ouvintes.
O segundo componente é o arquivo de traduções lib/l10n/traducoes.dart. Ele deve conter um objeto Translations.byText('pt_BR') com as seguintes strings traduzidas para inglês: todos os textos visíveis ao usuário nas telas de cardápio, carrinho e pedidos do aplicativo de delivery. No mínimo, as chaves devem incluir: 'Bem-vindo ao Delivery', 'Cardápio', 'Meu Carrinho', 'Meus Pedidos', 'Adicionar ao carrinho', 'Finalizar Pedido', 'Subtotal', 'Taxa de entrega', 'Total', 'Pedido em preparo', 'Pedido a caminho', 'Pedido entregue', 'Seu carrinho está vazio', 'Configurações', 'Idioma', 'Endereço de entrega', 'Retirar no local', 'Buscar produtos', 'Sem resultados para a busca', e 'Erro ao carregar o cardápio'. Além do objeto de tradução, o arquivo deve exportar uma extension TraduzirString on String com o getter i18n, o método fill(List<Object> params) e o método plural(int quantidade).
O terceiro componente é o PreferenciasIdiomaProvider, que deve estender ChangeNotifier. Ele mantém o Locale atual como campo privado com getter público localeAtual. Deve expor também uma lista constante idiomasDisponiveis com os dois locales suportados e um método nomeLegivel(Locale locale) que retorna 'Português (Brasil)' para pt-BR e 'English (US)' para en-US. O método carregarIdiomaSalvo() lê a preferência do SharedPreferences com a chave 'locale_delivery' e restaura o locale; se nenhuma preferência estiver salva, mantém o padrão Locale('pt', 'BR'). O método alterarIdioma(Locale novoLocale) atualiza o locale interno, chama I18n.define(novoLocale), persiste a preferência serializada como string ('pt_BR' ou 'en_US') e notifica os ouvintes. Uma consideração importante: o método alterarIdioma deve ser idempotente — se o locale solicitado for o mesmo que o atual, ele deve retornar sem fazer nada para evitar reconstruções desnecessárias.
O quarto componente é a TelaConfiguracoes, um StatelessWidget que usa Consumer<PreferenciasIdiomaProvider>. Ela deve exibir uma seção chamada 'Idioma'.i18n com um DropdownButton<Locale> que lista os idiomas disponíveis, exibe o nome legível de cada um, e chama provider.alterarIdioma(novoLocale) quando o usuário faz uma seleção. Abaixo do seletor de idioma, exiba um texto informativo que use o operador .i18n para demonstrar que a troca é instantânea — por exemplo, 'Configurações'.i18n como título da seção.
O quinto componente é uma tela de demonstração chamada TelaCardapio. Ela deve exibir um AppBar com título 'Cardápio'.i18n, uma mensagem de boas-vindas usando 'Bem-vindo ao Delivery'.i18n, e pelo menos dois cards de produto com os nomes traduzidos. Cada card deve mostrar o nome do produto, o preço formatado com NumberFormat.simpleCurrency(locale: provider.localeAtual.toString()) e um botão com o texto 'Adicionar ao carrinho'.i18n. Isso demonstra que a troca de idioma afeta simultaneamente as strings traduzidas pelo i18n_extension e a formatação numérica do intl.
Todos os cinco componentes devem estar no mesmo arquivo g_ex1.dart, que deve incluir também uma função main com o MaterialApp configurado e o MultiProvider registrando o PreferenciasIdiomaProvider. A função main deve chamar WidgetsFlutterBinding.ensureInitialized() e aguardar o carregarIdiomaSalvo() antes de executar o runApp, garantindo que o idioma salvo esteja disponível já no primeiro frame do aplicativo.
Para testar o funcionamento, entre na TelaConfiguracoes, troque o idioma de português para inglês, observe que todos os textos mudam imediatamente sem reinicialização, feche o aplicativo e abra novamente — o idioma deve permanecer como inglês.
Antes de implementar, reflita sobre três questões que dizem respeito à arquitetura da solução, não à sintaxe. Por que o I18n.define(novoLocale) deve ser chamado antes de notifyListeners() no método alterarIdioma, e o que acontece se a ordem for invertida? Por que o localeResolutionCallback no MaterialApp é necessário mesmo quando o PreferenciasIdiomaProvider já controla o locale, e qual é o papel específico de cada um desses mecanismos? Por que serializar o locale como uma string simples como 'pt_BR' em vez de armazenar o languageCode e o countryCode separadamente, e quais locales poderiam causar problemas com essa abordagem?
O método carregarIdiomaSalvo() deve ser chamado antes do runApp, mas ele é assíncrono. A forma correta de lidar com isso em Flutter é usar WidgetsFlutterBinding.ensureInitialized() no início da função main, transformá-la em Future<void> main() async, e usar await na chamada ao método. Se você não fizer isso, o runApp será executado antes que a preferência seja carregada, e o aplicativo sempre iniciará em português independentemente do idioma salvo, o que configura um bug sutil que não produz nenhum erro em tempo de execução.
O que deve ser entregue: um arquivo chamado g_ex1.dart, onde g é o nome do seu grupo.
Exercício 2
Acessibilidade nos Componentes Centrais do Delivery: Semantics, MergeSemantics e ExcludeSemantics
Um aplicativo acessível não descreve o que o usuário vê — descreve o que o usuário precisa saber
Imagine que você é um usuário do aplicativo de delivery com deficiência visual severa. Você ativou o TalkBack no seu Android e está tentando fazer um pedido. Você move o dedo pela tela e ouve: “imagem”, “texto”, “botão”, “texto”, “imagem”, “botão”. Não há como saber qual produto está descrito, qual é o preço, se o botão adiciona ao carrinho ou cancela o pedido, ou se o status exibido indica que o pedido já chegou ou ainda está sendo preparado. Esse aplicativo, apesar de visualmente correto, é completamente inacessível para você.
A acessibilidade em Flutter não é uma camada que se adiciona por cima da interface pronta — é uma propriedade de cada componente que precisa ser pensada durante o design. O widget Semantics permite que você associe a cada elemento da interface uma descrição que faz sentido do ponto de vista do usuário, não do ponto de vista visual. O MergeSemantics permite que elementos relacionados sejam anunciados juntos como uma unidade coerente. O ExcludeSemantics remove da árvore de semântica elementos puramente decorativos que, se anunciados, apenas criariam ruído para o usuário. Juntos, esses três widgets definem a diferença entre um aplicativo que o TalkBack consegue operar e um que apenas frustra.
O primeiro componente que você deve implementar é o CardProduto, um widget que representa um produto no cardápio do delivery. Visualmente, ele exibe uma área de imagem à esquerda (um container colorido com o nome do produto em negrito, representando a imagem), e à direita o nome do produto, a descrição curta, o preço e um botão de adicionar ao carrinho. Para o TalkBack, este card deve ser anunciado como uma unidade coerente usando MergeSemantics como raiz — mas com um detalhe importante: o conteúdo visual e o conteúdo semântico devem contar histórias coerentes. A imagem do produto é decorativa do ponto de vista da semântica — envolva-a com ExcludeSemantics. O nome, a descrição e o preço serão unidos pelo MergeSemantics em um único anúncio. Para o botão de adicionar ao carrinho dentro do card, use um Semantics separado com button: true, label descrevendo o produto pelo nome e hint orientando a ação — assim o TalkBack distingue o card do botão e o usuário pode ativar o botão por toque duplo após ouvi-lo anunciado. O card inteiro (exceto o botão) deve ser envolvido por um GestureDetector para a ação de navegar até a tela de detalhes do produto.
O segundo componente é o BotaoAdicionarAoCarrinho como widget independente, para uso em contextos onde o botão aparece fora do card — por exemplo, na tela de detalhes do produto. Ele deve receber nomeProduto, precoProduto (como double), e um callback aoAdicionar. O Semantics que o envolve deve usar label com o texto completo e contextual, incluindo o nome do produto e o preço formatado com NumberFormat.simpleCurrency(locale: 'pt_BR'), o atributo button: true e o hint adequado. O ícone de carrinho dentro do botão deve ser excluído da semântica com ExcludeSemantics, pois a informação que ele carrega já está expressa no label. O texto “Adicionar” dentro do botão também deve ser excluído para evitar que o TalkBack repita informação.
O terceiro componente é o IndicadorStatusPedido, que exibe visualmente o estado atual de um pedido. Ele recebe um enum StatusPedido com os valores emPreparo, aCaminho e entregue, e exibe um ícone correspondente e um texto de status. A lógica de acessibilidade aqui é mais sutil: o status de um pedido muda dinamicamente enquanto o usuário está com o aplicativo aberto — quando o status muda de aCaminho para entregue, o usuário com TalkBack precisa ser notificado sem precisar navegar até o elemento. Para isso, o Semantics que envolve o widget de status deve ter liveRegion: true, garantindo que o TalkBack anuncie automaticamente a mudança. Além disso, o label semântico do indicador deve expressar o status de forma completa — por exemplo, 'Status do pedido: Pedido a caminho' — enquanto os elementos visuais internos (ícone e texto) ficam excluídos da semântica com ExcludeSemantics para evitar a duplicação do anúncio.
O quarto componente é o ContadorCarrinho, um badge que aparece sobre o ícone do carrinho na AppBar do aplicativo. Ele exibe o número de itens no carrinho. Para o TalkBack, esse badge deve ser anunciado com semântica que inclua tanto a função (abrir o carrinho) quanto o estado atual (número de itens). Use Semantics com label: 'Carrinho de compras com ${quantidade} ${quantidade == 1 ? "item" : "itens"}', button: true e hint: 'Toque duas vezes para abrir o carrinho'. O ícone e o badge numérico internos devem ser excluídos da semântica.
O quinto componente é uma TelaCardapio de demonstração que usa os quatro componentes anteriores com dados estáticos simulados. Ela deve exibir pelo menos três produtos no cardápio, o contador de carrinho com pelo menos um item, e um indicador de status para um pedido em andamento. Adicione um botão de alternância que muda o StatusPedido entre os três valores para demonstrar o liveRegion em funcionamento. A tela deve ser estruturada de forma que a navegação com o TalkBack faça sentido lógico: primeiro o AppBar com o título e o contador de carrinho, depois os cards de produto em ordem, com o botão de cada card acessível como entidade separada.
Todos os componentes devem estar no mesmo arquivo g_ex2.dart, com uma função main e um MaterialApp mínimo para demonstração. Não é necessário integrar com o PreferenciasIdiomaProvider do exercício anterior neste arquivo, mas os textos de label semântico devem ser escritos em português, que é o idioma padrão da aplicação.
Reflita sobre as seguintes questões antes de implementar. Qual é a diferença entre o que o TalkBack anuncia quando você usa MergeSemantics em torno de um card e o que anuncia quando não o usa? Por que excluir o ícone decorativo da semântica com ExcludeSemantics é uma decisão de qualidade de experiência, não apenas de conformidade técnica? Qual seria o comportamento do liveRegion se o IndicadorStatusPedido fosse substituído por um completamente novo widget na árvore em vez de atualizado in-place — e como você garantiria que o anúncio ainda seja feito corretamente?
O MergeSemantics tem um comportamento que pode surpreender: se qualquer descendente dentro da sua subárvore declarar um Semantics com excludeSemantics: true, a fusão pode não funcionar como esperado. O padrão correto é usar ExcludeSemantics como widget separado para os elementos decorativos, e reservar Semantics com propriedades declarativas apenas para os elementos que precisam descrever informação. Não misture ExcludeSemantics com MergeSemantics de forma aninhada sem compreender completamente a ordem de precedência, pois isso pode resultar em nós semânticos que o TalkBack não consegue alcançar ou que anuncia de forma fragmentada.
O que deve ser entregue: um arquivo chamado g_ex2.dart, onde g é o nome do seu grupo.
Exercício 3
Internacionalização Avançada e Acessibilidade com Gerenciamento de Foco
Os detalhes que separam uma implementação funcional de uma implementação profissional
Você já sabe configurar o i18n_extension e adicionar semântica básica. Este exercício trata do que vai além do básico: pluralização gramatical com a classe Plural, interpolação de variáveis dinâmicas em strings traduzidas, formatação de moeda e data que muda junto com o locale ativo, gerenciamento programático do foco de acessibilidade com FocusNode, anúncios semânticos explícitos com SemanticsService.announce para eventos que não envolvem mudanças visuais imediatas, e adaptação do layout ao TextScaler configurado pelo usuário no sistema operacional.
Cada um desses tópicos resolve um problema específico que aparece quando o aplicativo é usado por pessoas reais em condições reais. A pluralização resolve o problema gramatical de exibir “1 item no carrinho” e “3 itens no carrinho” com as formas corretas em cada idioma. A interpolação permite construir mensagens dinâmicas como “Olá, João! Você tem 2 novos pedidos” de forma que a tradução preserve a posição dos valores no contexto de cada idioma. A formatação regional garante que o preço de R$ 29,90 não apareça como “$29.90” para um usuário brasileiro. O gerenciamento de foco garante que após a conclusão de uma ação importante — como finalizar um pedido — o foco do TalkBack vá automaticamente para a confirmação em vez de permanecer no botão que acabou de ser ativado. E o TextScaler resolve o problema dos layouts que quebram quando o usuário configura o sistema para exibir fontes maiores.
O primeiro componente a implementar é um arquivo de traduções estendido lib/l10n/traducoes_avancadas.dart. Ele deve incluir as strings do exercício anterior e adicionar as seguintes entradas com pluralização e interpolação. Para pluralização, adicione: 'Um item no carrinho' com forma zero sendo 'Carrinho vazio', forma one sendo '1 item no carrinho' e forma many sendo '%s itens no carrinho', com as versões correspondentes em inglês ('Cart is empty', '1 item in cart', '%s items in cart'). Adicione também 'Um produto disponível' com formas análogas para o contexto do cardápio. Para interpolação, adicione: 'Olá, %s! Bem-vindo de volta.' com a versão em inglês 'Hello, %s! Welcome back.', 'Seu pedido #%s foi confirmado.' com 'Your order #%s has been confirmed.', e 'Estimativa de entrega: %s minutos' com 'Estimated delivery: %s minutes'. A extension deve expor o getter i18n, o método fill(List<Object> params) e o método plural(int quantidade).
O segundo componente é a classe FormataLocale, com métodos estáticos para formatação regionalizada. O método moeda(double valor, String locale) usa NumberFormat.simpleCurrency(locale: locale). O método dataAbreviada(DateTime data, String locale) usa DateFormat.yMd(locale). O método dataExtensa(DateTime data, String locale) usa DateFormat('EEEE, d \'de\' MMMM \'de\' y', locale) para português e DateFormat('EEEE, MMMM d, y', locale) para inglês — a distinção deve ser feita verificando locale.startsWith('pt'). O método horaFormatada(DateTime data, String locale) usa DateFormat.Hm(locale). Todos os métodos devem receber o locale como String, não como Locale, pois o DateFormat e o NumberFormat esperam a representação textual.
O terceiro componente é o TelaCarrinho, um StatefulWidget completo. Ele deve exibir a lista de itens do carrinho com os preços formatados pelo FormataLocale.moeda, o subtotal e o total calculados e formatados, a contagem de itens usando .plural(quantidade) — de forma que a mensagem mude entre “1 item no carrinho” e “3 itens no carrinho” com a forma correta —, e um botão “Finalizar Pedido” com semântica adequada. Quando o usuário toca em “Finalizar Pedido”, deve ocorrer a seguinte sequência: o botão fica desabilitado durante o processamento (defina _processando = true e reconstrua com setState), após um delay simulado de 1 segundo o estado muda para confirmado, e ao final SemanticsService.announce('Pedido finalizado com sucesso. Você receberá a confirmação em breve.', TextDirection.ltr) é chamado explicitamente. Essa chamada ao SemanticsService.announce é a técnica que instrui o TalkBack a anunciar uma mensagem específica sem que ela precise estar atualmente visível na tela — é o equivalente móvel do aria-live da web. Após a chamada, um Semantics com liveRegion: true exibe a confirmação textual em tela. O botão deve ter Semantics com button: true, label descrevendo a ação completa incluindo o valor total, e enabled refletindo o estado !_processando.
O quarto componente é o FormularioEnderecoEntrega, um StatefulWidget com três campos: nome da rua, número e complemento. O gerenciamento de foco deve ser implementado com FocusNode explícito para cada campo. Ao pressionar “Enter” ou “Próximo” no teclado do campo de nome da rua, o foco deve avançar automaticamente para o campo de número usando FocusScope.of(context).requestFocus(_foco Numero). Do campo de número para o complemento, o mesmo comportamento. Do campo de complemento, ao pressionar “Enter”, o foco deve ser liberado com _focoComplemento.unfocus(). Ao submeter o formulário, valide os campos (nome da rua e número são obrigatórios) e use SemanticsService.announce para anunciar o resultado da validação: em caso de sucesso, 'Endereço salvo com sucesso.'; em caso de erro, 'Erro: preencha todos os campos obrigatórios.'. Os TextFormField devem ter labelText e hintText traduzidos com .i18n.
O quinto componente é o CardProdutoResponsivo, uma versão do CardProduto do exercício anterior que se adapta ao TextScaler. Ele não deve usar altura fixa em nenhum container que envolva texto — nem SizedBox(height: ...) nem Container(height: ...) quando o conteúdo inclui elementos textuais. Em vez disso, use IntrinsicHeight para o Row principal, ou simplesmente deixe os containers sem height definida. Para demonstrar o problema e a solução, a TelaDemo deve incluir um Slider que altera o textScaleFactor de exibição usando MediaQuery com copyWith(textScaler: TextScaler.linear(fator)). Com o fator em 2.0, o layout do CardProdutoResponsivo deve permanecer correto, enquanto uma versão de referência com altura fixa (que você implementa apenas para comparação) deve quebrar visivelmente. Esse contraste didático solidifica o entendimento do problema.
Reúna os cinco componentes em um único arquivo g_ex3.dart com uma main que exibe uma demonstração navegável com pelo menos quatro rotas: TelaCardapio (usando o CardProdutoResponsivo), TelaCarrinho, FormularioEnderecoEntrega e uma TelaConfiguracoes com o seletor de idioma e o slider de escala de fonte. Use Navigator.push para a navegação entre elas. O aplicativo deve funcionar corretamente em ambos os idiomas (português e inglês), com textos, preços e datas formatados de acordo com o locale selecionado.
Reflita sobre as questões a seguir antes de escrever o código. Por que SemanticsService.announce é necessário para anunciar a confirmação de pedido, se você poderia simplesmente adicionar um Semantics com liveRegion: true em uma mensagem que aparece na tela? Em que situações o liveRegion é suficiente e em que situações o SemanticsService.announce é a ferramenta correta? Por que gerenciar o foco explicitamente com FocusNode em formulários melhora a experiência não apenas para usuários com TalkBack, mas para todos os usuários com teclado físico conectado via Bluetooth? E por que evitar alturas fixas em containers de texto é uma decisão correta mesmo para usuários sem nenhuma configuração especial de acessibilidade?
O SemanticsService.announce é uma ferramenta poderosa mas que deve ser usada com parcimônia. Cada chamada interrompe o que o TalkBack estava anunciando no momento e substitui pelo novo anúncio. Se você chamar esse método em resposta a eventos frequentes — como mudanças de quantidade no carrinho a cada toque no botão de incremento —, você cria uma experiência auditiva caótica e desorientadora. Reserve essa chamada para eventos significativos e pontuais: confirmação de pedido, erro de validação, conclusão de upload. Para feedback de ações repetitivas, prefira o liveRegion com debounce adequado.
O que deve ser entregue: um arquivo chamado g_ex3.dart, onde g é o nome do seu grupo.