【Flutter】slangを使用した多言語対応

## はじめに
slangパッケージを使用して多言語対応の基本的な実装をしていきたいと思います。
slang を使用することで、翻訳ファイルの管理、言語切り替えの機能を簡単に実装できるようになります。

https://pub.dev/packages/slang

## 環境
- Flutter 3.16.5
- パッケージは以下を参照（初期から追加したもののみ記載しています）

```pubspec.yaml
dependencies:
  slang: ^3.28.0
  slang_flutter: ^3.28.0
  flutter_riverpod: ^2.4.9
  flutter_localizations:
    sdk: flutter

dev_dependencies:
  build_runner: ^2.4.7
  slang_build_runner: ^3.28.0
```

- [slang](https://pub.dev/packages/slang) と [slang_flutter](https://pub.dev/packages/slang_flutter) : 多言語対応のための主要なパッケージ。
- [flutter_riverpod](https://pub.dev/packages/flutter_riverpod): 状態管理のために使用。
- [flutter_localization](https://pub.dev/packages/flutter_localization): Flutterのローカライズ機能を利用。
- [build_runner](https://pub.dev/packages/build_runner) と [slang_build_runner](https://pub.dev/packages/slang_build_runner): コード生成のために使用。

## 完成イメージ
アプリのボトムナビゲーションバーにある「settings」ページを通じて、言語を簡単に切り替えることができるようなものを想定します。
![slang_sample1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2658262/df64af47-ea54-2f36-29e3-a931cb477ef5.gif)

## プロジェクト構成
※構成はあくまで一例です。

![スクリーンショット 2023-12-26 11.19.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2658262/7ba04914-91a6-e102-edd0-4cdedd08727b.png)
- i18n:アプリの多言語対応に必要な翻訳ファイルが格納
- pages:UIに関連するファイルが格納
- providers:状態管理に関連するファイルが格納



## 実装手順

### 1.JSONファイルの作成
`slang`がサポートしているファイルの種類は JSON (デフォルト)、YAML、CSV、およびARBです。

今回は、JSONを使用し、各言語に対応する以下の翻訳ファイルを作成します。
- strings.i18n.json:英語対応用（アプリのデフォルト言語）
- strings_ja.i18n.json:日本語対応用
- strings_zh.i18n.json:中国語対応用

他の言語について対応する際には[flutter_localizationsのサポート言語のコード](https://api.flutter.dev/flutter/flutter_localizations/GlobalMaterialLocalizations-class.html#:~:text=and%20time%20formatting.-,Supported%20languages,-This%20class%20supports) を参考にしてもいいかと思います。

:::note info
i18nとは
i18nは「Internationalization（国際化）」の略で、18は「I」と「n」の間にある文字の数を表しています。
:::

```strings.i18n.json
{
  "navigationBar": {
    "home": {
      "title": "home"
    },
    "settings": {
      "title": "settings"
    }
  },
  "settings": {
    "language": {
      "title": "Language",
      "currentLanguage": "English"
    }
  },
  "locales(map)": {
    "en": "English",
    "ja": "Japanese",
    "zh": "Chinese"
  }
}
```

```strings_ja.i18n.json
{
  "navigationBar": {
    "home": {
      "title": "ホーム"
    },
    "settings": {
      "title": "設定"
    }
  },
  "settings": {
    "language": {
      "title": "言語",
      "currentLanguage": "日本語"
    }
  },
  "locales(map)": {
    "en": "英語",
    "ja": "日本語",
    "zh": "中国語"
  }
}
```

```strings_zh.i18n.json
{
  "navigationBar": {
    "home": {
      "title": "首页"
    },
    "settings": {
      "title": "设置"
    }
  },
  "settings": {
    "language": {
      "title": "语言",
      "currentLanguage": "中文"
    }
  },
  "locales(map)": {
    "en": "英语",
    "ja": "日语",
    "zh": "中文"
  }
}
```

ファイルを自動生成するコマンドを実行します。
```zsh
flutter pub run build_runner build
```
`strings.g.dart`が生成されたらOKです。

### 2.状態管理の設定
Riverpodを使用して、アプリの言語設定を管理するための LocaleNotifier クラスとプロバイダーを定義します。

##### 全コード
```locale_state.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:slang_sample/i18n/strings.g.dart';

class LocaleNotifier extends Notifier<AppLocale> {
  @override
  build() {
    // デバイスの現在のロケールを初期状態として設定
    return LocaleSettings.currentLocale;
  }

  // 新しいロケール設定
  Future<void> changeLocale(AppLocale newLocale) async {
    state = newLocale;
    LocaleSettings.setLocale(newLocale);
  }
}

final localeProvider =
    NotifierProvider<LocaleNotifier, AppLocale>(LocaleNotifier.new);
```

- `LocaleNotifier`は Notifier<AppLocale> を継承しており、アプリの現在のロケール（言語設定）を表すAppLocale型のデータを管理します。

- `build`メソッドで、クラスの初期化時にデバイスの現在のロケールを初期状態として設定しています。

- `changeLocale`メソッドで、新しいロケール（newLocale）を受け取り、アプリの内部状態と全体のロケール設定を更新します。

- `localeProvider`は NotifierProvider を使用して LocaleNotifier のインスタンスを提供しています。

### 3.アプリの多言語対応の基本設定
`main.dart`に、アプリの多言語対応の基本的な設定を行っていきます。

###### 全コード
```main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:slang_sample/i18n/strings.g.dart';
import 'package:slang_sample/pages/main_page.dart';
import 'package:slang_sample/providers/locale_state.dart';

void main() async {
  // Flutterのウィジェットバインディングの初期化
  WidgetsFlutterBinding.ensureInitialized();
  // デバイスのロケール設定に基づいて初期言語を設定
  LocaleSettings.useDeviceLocale();
  runApp(ProviderScope(
    // アプリ内で言語を変更時に、その変更に更新
    child: TranslationProvider(
      child: const MyApp(),
    ),
  ));
}

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final currentLocale = ref.watch(localeProvider);
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      // 現在選択されている言語（ロケール）
      locale: currentLocale.flutterLocale,
      supportedLocales: AppLocaleUtils.supportedLocales,
      localizationsDelegates: GlobalMaterialLocalizations.delegates,
      home: const MainPage(),
    );
  }
}
```

#### ①アプリの初期設定と多言語対応の基盤
```dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  LocaleSettings.useDeviceLocale();
  
  runApp(ProviderScope(
    child: TranslationProvider(
      child: const MyApp(),
    ),
  ));
}
```
- `WidgetsFlutterBinding.ensureInitialized()` でFlutterアプリのウィジェットやその他の機能を使用する前に必要な初期化プロセスを行います。

- `LocaleSettings.useDeviceLocale()` でアプリがユーザーのデバイスの言語設定を自動的に使用するように設定します。これにより、アプリはユーザーの言語環境に合わせて起動します。

- `TranslationProvider`は、アプリ内で使用される現在の言語（ロケール）を管理し、ユーザーが言語設定を変更した場合に、アプリ全体で一貫した言語が使用されるようにします。

#### ②現在のロケールの監視と設定

```dart
    final currentLocale = ref.watch(localeProvider);
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      locale: currentLocale.flutterLocale,
      // ...略
    );
```
- ロケールの監視
`final currentLocale = ref.watch(localeProvider);`は、localeProvider から現在のロケール（言語設定）を監視し、取得します。

- ロケールの設定
`locale` に`currentLocale.flutterLocale;` にすることで、監視された現在のロケールを設定します。これにより、アプリの言語がユーザーの選択に基づいて適切に表示されます。

#### ③アプリの多言語対応設定
- サポートされる言語の定義
`supportedLocales` はアプリが対応する言語の範囲を定義します。これには、json ファイルで定義された言語のリストが含まれ、アプリがこれらの言語で表示できるようになります。
- 言語リソースの提供
`localizationsDelegates` は上記の言語でのアプリのテキストやレイアウトをサポートされる言語に合わせて自動的に調整するための仕組みを提供します。

https://api.flutter.dev/flutter/flutter_localizations/GlobalMaterialLocalizations-class.html

### 4.ページの実装
UIを構築します。
※ホーム画面やボトムナビゲーションバーのコードは割愛しています。

#### (1) 設定画面の作成
こちらの画面を作成します。

<img width=250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2658262/2c957ac5-3476-04cf-107b-b84bbd8265d0.png">

##### 全コード
```settings_page.dart
import 'package:flutter/material.dart';
import 'package:slang_sample/i18n/strings.g.dart';
import 'package:slang_sample/pages/locale_page.dart';

enum SettingOption {
  language,
  // 他の設定項目があれば記述。ex)themeMode
}

class SettingsPage extends StatelessWidget {
  const SettingsPage({super.key});

  @override
  Widget build(BuildContext context) {
    final translations = Translations.of(context);
    return ListView.builder(
      itemCount: SettingOption.values.length,
      itemBuilder: (context, index) {
        final option = SettingOption.values[index];
        switch (option) {
          case SettingOption.language:
            return _SettingsListItem(
              icon: Icons.language,
              title: translations.settings.language.title,
              trailing: Text(translations.settings.language.currentLanguage),
              onTap: () {
                Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: (context) => const LocalePage(),
                  ),
                );
              },
            );
        }
      },
    );
  }
}

class _SettingsListItem extends StatelessWidget {
  const _SettingsListItem({
    required this.icon,
    required this.title,
    required this.trailing,
    required this.onTap,
  });

  final IconData icon;
  final String title;
  final Widget trailing;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: Icon(icon),
      title: Text(title),
      trailing: trailing,
      onTap: onTap,
    );
  }
}
```

##### ①enum（列挙型）の使用理由
```dart
enum SettingOption {
  language,
  // 他の設定項目があれば記述。ex)themeMode
}
```
この enum は、設定ページに表示される異なる設定項目（例: language, themeMode）を一覧化します。これにより、どのような設定項目があるのかが一目でわかり、将来的に新しい項目を追加する際も簡単に行えます。

また、ListView.builder の中で switch 文を使って、選択された SettingOption に基づいて異なるリストアイテムを生成します。これにより、同じパターンのコードを再利用し、アプリの保守性を高めることができます。
```dart
itemBuilder: (context, index) {
  final option = SettingOption.values[index];
  switch (option) {
    case SettingOption.language:
      return _SettingsListItem(
        // 言語設定に関するリストアイテムの設定
      );
    case SettingOption.themeMode:
      return _SettingsListItem(
        // テーマモード設定に関するリストアイテムの設定
      );
  }
}
```

##### ②多言語のテキストを取得
```dart
final translations = Translations.of(context);
//...略
title: translations.settings.language.title,
trailing: Text(translations.settings.language.currentLanguage),
```
`Translations.of(context)`で、現在の`context`に基づいて、適切な翻訳されたテキストを取得しており、json ファイルから、各テキストを読み込んでいます。

#### (2) 選択画面の作成
こちらの画面を作成します。

<img width=250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2658262/3198d652-5957-b751-f4ef-7c24d0e22e0b.png">

##### 全コード
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:slang_sample/i18n/strings.g.dart';
import 'package:slang_sample/providers/locale_state.dart';

class LocalePage extends ConsumerWidget {
  const LocalePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final translations = Translations.of(context);
    final localeState = ref.watch(localeProvider);
    final localeNotifier = ref.read(localeProvider.notifier);
    return Scaffold(
      appBar: AppBar(
        title: Text(translations.settings.language.title),
      ),
      body: SafeArea(
        child: ListView.builder(
          itemCount: AppLocale.values.length,
          itemBuilder: (context, index) {
            final locale = AppLocale.values[index];
            return RadioListTile<AppLocale>(
              value: locale,
              groupValue: localeState,
              title: Text(translations.locales[locale.languageTag]!),
              onChanged: (value) {
                if (value != null) {
                  localeNotifier.changeLocale(value);
                }
              },
            );
          },
        ),
      ),
    );
  }
}
```

##### ①状態管理
```dart
final localeState = ref.watch(localeProvider);
final localeNotifier = ref.read(localeProvider.notifier);
```

- 状態の監視
`ref.watch(localeProvider)` は、localeProvider が管理する現在のロケール（言語設定）の状態を監視します。これにより、ユーザーが言語設定を変更した際に、UIがリアルタイムで更新されるようになります。

- 状態の更新
`ref.read(localeProvider.notifier) `は、localeProvider の Notifier を取得します。

##### ②AppLocaleについて
`AppLocale` は、アプリでサポートされる言語を表す enum（列挙型）です。`AppLocale.values` は、サポートされるすべての言語のリストを返します。つまり今回定義している日本語、英語、中国語が返されます。

##### ③RadioListTile での言語選択の処理
```dart
child: ListView.builder(
  itemCount: AppLocale.values.length,
  itemBuilder: (context, index) {
    final locale = AppLocale.values[index];
    return RadioListTile<AppLocale>(
      value: locale,
      groupValue: localeState,
      title: Text(translations.locales[locale.languageTag]!),
      onChanged: (value) {
        if (value != null) {
          localeNotifier.changeLocale(value);
        }
      },
    );
  },
),
```


ユーザーがリスト内の任意の言語を選択すると、onChanged コールバックが実行されます。

onChanged 内で、localeNotifier.changeLocale(value) を呼び出すことで、選択された言語（value）に基づいてアプリのロケールが更新されます。

### 5.デバイスの言語設定を変えた時の挙動確認
シミュレータでの言語設定を英語にすると、今回作成したテキストも適切に切り替わっています。
<img width=250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2658262/e499fb38-c4f5-220d-64b6-1461ae84b3e4.png">

<img width=250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2658262/e17f7539-af82-dee4-634b-130f0614a1c9.png">