Como ler strings localizadas fora dos widgets usando Riverpod

Tempo de leitura: 4 minutes

Se quiser implantar seu aplicativo Flutter para usuários que falam outro idioma, você precisará localizá-lo. E a documentação do Flutter já possui um guia detalhado de internacionalização que aborda esse tópico em profundidade.

Mas se tivermos alguma lógica de negócios fora de nossos widgets, como podemos ler as strings localizadas?

Este artigo mostra como abordo a localização usando o pacote Riverpod em meus próprios aplicativos.

Este artigo é baseado no pacote oficial flutter_localization, que suporta mais de 78 localidades. O site Flutter Gems lista muitos outros pacotes de localização aqui.

 

Requisitos de localização de aplicativos

Antes de mergulharmos no código, vamos descobrir o que queremos fazer:

  • acessar strings localizadas fora de nossos widgets (sem usar BuildContext)
  • garantir que todos os ouvintes (provedores e widgets) sejam reconstruídos quando a localidade for alterada

Para satisfazer esses requisitos, precisamos combinar duas coisas:

  • um Provider<AppLocalizations> que podemos usar em qualquer lugar do aplicativo
  • uma classe LocaleObserver que podemos usar para rastrear alterações de localidade

Veja como implementá-los. 👇

 

1. Criando o provedor AppLocalizations

Para obter o objeto AppLocalizations correto para a localidade atual, podemos usar o método lookupAppLocalizations, que é gerado quando executamos o comando flutter gen-l10n.

Portanto, o primeiro passo é criar um provedor que o utilize:

import 'package:flutter_riverpod/flutter_riverpod.dart';
// lookupAppLocalizations é definido aqui 👇
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 
import 'dart:ui' as ui;

/// provedor usado para acessar o objeto AppLocalizations para a localidade atual
final appLocalizationsProvider = Provider<AppLocalizations>((ref) {
  return lookupAppLocalizations(ui.window.locale);
});

Mas isso não é suficiente porque nosso provedor não reconstruirá e notificará seus ouvintes quando a localidade for alterada.

 

2. Adicionando um Locale Observer

Para acompanhar as alterações de localidade, podemos criar uma subclasse de WidgetsBindingObserver e substituir o método didChangeLocales:

/// observador usado para notificar o chamador quando a localidade muda
class _LocaleObserver extends WidgetsBindingObserver {
  _LocaleObserver(this._didChangeLocales);
  final void Function(List<Locale>? locales) _didChangeLocales;
  @override
  void didChangeLocales(List<Locale>? locales) {
    _didChangeLocales(locales);
  }
}

E agora que temos isso, podemos atualizar nosso código de provedor:

/// provedor usado para acessar o objeto AppLocalizations para a localidade atual
final appLocalizationsProvider = Provider<AppLocalizations>((ref) {
  // 1. inicializar a partir do local inicial
  ref.state = lookupAppLocalizations(ui.window.locale);
  // 2. crie um observador para atualizar o estado
  final observer = _LocaleObserver((locales) {
    ref.state = lookupAppLocalizations(ui.window.locale);
  });
  // 3. registrar o observador e descartá-lo quando não for mais necessário
  final binding = WidgetsBinding.instance;
  binding.addObserver(observer);
  ref.onDispose(() => binding.removeObserver(observer));
  // 4. devolver o estado
  return ref.state;
});

Isso garante que quaisquer outros provedores ou widgets que dependam de appLocalizationsProvider serão reconstruídos quando a localidade for alterada.

A seguir, vamos ver como usar nosso provedor.

 

3. Usando o provedor AppLocalizations

A ideia principal é que possamos ler o appLocalizationsProvider usando um objeto ref:

// obtenha o objeto AppLocalizations (leia uma vez)
final loc = ref.read(appLocalizationsProvider);
// leia uma propriedade definida no arquivo *.arb
final error = loc.addToCartFailed;

Por exemplo, considere esta classe CartService usada para atualizar um carrinho de compras:

class CartService {
  CartService(this.ref);
  // declarar ref como uma propriedade
  final Ref ref;

  Future<void> addItem(Item item) async {
    try {
      // buscar o carrinho
      final cart = ref.read(cartRepositoryProvider).fetchCart();
      // retornar uma cópia com os dados atualizados
      final updated = cart.addItem(item);
      // configure o carrinho com os dados atualizados
      await ref.read(cartRepositoryProvider).setCart(updated);
    } catch (e) {
      // receba a mensagem de erro localizada
      final errorMessage = ref.read(appLocalizationsProvider).addToCartFailed;
      // jogue isso como uma exceção
      throw Exception(errorMessage);
    }
  }
}

// o fornecedor correspondente
final cartServiceProvider = Provider<CartService>((ref) {
  return CartService(ref);
});

Esta classe CartService recebe um argumento Ref, que é usado para chamar ref.read(appLocalizationsProvider) dentro do bloco catch – tornando assim AppLocalizations uma dependência implícita.

Nesse caso, devemos ler (não observar) o appLocalizationsProvider, pois precisamos recuperar a mensagem de erro uma vez quando o método addItem for chamado.

Mas se quisermos tornar a dependência de AppLocalizations mais explícita, podemos fazer isso:

class CartService {
  CartService({required this.cartRepository, required this.loc});
  // declarar dependências como propriedades explícitas
  final CartRepository cartRepository;
  final AppLocalizations loc;

  Future<void> addItem(Item item) async {
    try {
      // buscar o carrinho
      final cart = cartRepository.fetchCart();
      // retornar uma cópia com os dados atualizados
      final updated = cart.addItem(item);
      // configure o carrinho com os dados atualizados
      await cartRepository.setCart(updated);
    } catch (e) {
      // receba a mensagem de erro localizada
      final errorMessage = loc.addToCartFailed;
      // jogue isso como uma exceção
      throw Exception(errorMessage);
    }
  }
}

// o fornecedor correspondente
final cartServiceProvider = Provider<CartService>((ref) {
  return CartService(
    // passe as dependências explicitamente (usando watch)
    cartRepository: ref.watch(cartRepositoryProvider),
    loc: ref.watch(appLocalizationsProvider),
  );
});

Nesse caso, devemos observar (não ler) o appLocalizationsProvider para reconstruir o CartService retornado pelo cartServiceProvider quando a localidade for alterada.

De qualquer forma, agora podemos acessar AppLocalizations em qualquer lugar dentro de nossos provedores sem usar BuildContext.

 

Bônus: usando AppLocalizations para tratamento de erros

Se usarmos freezed, podemos definir nossa própria classe de exceção específica de domínio:

@freezed
class AppException with _$AppException {
  const factory AppException.permissionDenied() = PermissionDenied;
  const factory AppException.paymentFailed() = PaymentFailed;
  // adicione outros tipos de erro aqui
}

E se quisermos mapear cada erro para uma mensagem de erro localizada (que mostraremos na UI), podemos criar esta extensão simples:

extension AppExceptionMessage on AppException {
  String message(AppLocalizations loc) {
    return when(
      permissionDenied: () => loc.permissionDeniedMessage,
      paymentFailed: () => loc.paymentFailedMessage,
      // e assim por diante...
    );
  }
}

Então, podemos definir um estado de erro com uma string localizada dentro de uma subclasse StateNotifier<AsyncValue> que nossos widgets podem ouvir:

// dentro de uma subclasse StateNotifier
final exception = AppException.permissionDenied();
final loc = ref.read(appLocalizationsProvider);
state = AsyncError(exception.message(loc));

Mais uma vez, nenhum BuildContext é necessário para ler strings localizadas fora de nossos widgets.

 

Conclusão

Agora descobrimos como acessar strings localizadas em nosso aplicativo:

  • Dentro dos widgets → obtenha o objeto AppLocalizations do BuildContext
  • Fora dos widgets → obtenha o objeto AppLocalizations do appLocalizationsProvider

De qualquer forma, nossos widgets e provedores serão reconstruídos se a localidade mudar.

Para obter instruções sobre como testar a alteração de localidade no iOS e Android, consulte este projeto de exemplo no GitHub:

E fique à vontade para reutilizar esse código em seus aplicativos, se desejar:

import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'dart:ui' as ui;

/// provedor usado para acessar o objeto AppLocalizations para a localidade atual
final appLocalizationsProvider = Provider<AppLocalizations>((ref) {
  // 1. inicializar a partir do local inicial
  ref.state = lookupAppLocalizations(ui.window.locale);
  // 2. crie um observador para atualizar o estado
  final observer = _LocaleObserver((locales) {
    ref.state = lookupAppLocalizations(ui.window.locale);
  });
  // 3. registrar o observador e descartá-lo quando não for mais necessário
  final binding = WidgetsBinding.instance;
  binding.addObserver(observer);
  ref.onDispose(() => binding.removeObserver(observer));
  // 4. devolver o estado
  return ref.state;
});

/// observador usado para notificar o chamador quando a localidade for alteradaes
class _LocaleObserver extends WidgetsBindingObserver {
  _LocaleObserver(this._didChangeLocales);
  final void Function(List<Locale>? locales) _didChangeLocales;
  @override
  void didChangeLocales(List<Locale>? locales) {
    _didChangeLocales(locales);
  }
}