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:

dependencies:
  i18n_extension: ^15.1.0
  intl: ^0.19.0

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]),
)
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.

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

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:

// Útil durante o desenvolvimento para verificar a árvore semântica.
// Remova antes de publicar.
WidgetsBinding.instance.addPostFrameCallback((_) {
  debugDumpSemanticsTree();
});

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.simpleCurrency de acordo com o locale?
  • As datas são formatadas com DateFormat de acordo com o locale?
  • A preferência de idioma é salva e restaurada entre sessões?
  • O localeResolutionCallback detecta 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 MergeSemantics para 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.