Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け) の次に揃えておきたいのが、文言の置き場です。日本語業務アプリでは、画面タイトル、入力ラベル、件数表示、バリデーションメッセージを最初から日本語で出したくなります。そこで Text('固定文言') を増やし始めると、あとでフォームやログイン画面へ広げるときに置換が増えます。この記事では Flutter 標準の l10n と arb を使い、日本語化の最小構成を pubspec.yaml、l10n.yaml、lib/l10n/*.arb、lib/main.dart の 4 点で作ります。
1. ゴールと非対象
対象読者
- Flutter の環境構築、基本 UI、REST API 通信、設定保存の入口までは進んでいる人
- 日本語 UI を入れたいが、
l10n/arb/ 生成コードの役割がまだ曖昧な人 - フォームやログイン画面へ進む前に、文言の管理方法を先に揃えたい人
この記事で到達する状態
flutter_localizations、intl、generate: true、l10n.yamlの役割を説明できるlib/l10n/app_ja.arbとapp_en.arbを作成できるAppLocalizationsをMaterialAppと各 Widget から参照できる- 引数付き文言、複数形、バリデーションメッセージの最小例を動かせる
- 日本語切り替えをどこで行い、どの画面要素から localize すべきかを説明できる
非対象
intlパッケージを直接書く設計l10n.yamlの詳細カスタマイズや多言語ファイル分割easy_localizationなど外部ライブラリとの比較- 日付、数値、通貨フォーマットの細かいロケール差
- Riverpod や go_router と組み合わせた本格的なアプリ全体設計
今回は「最初の 1 本」を動かすところに絞ります。日本語だけに閉じず、英語も 1 つ入れて切り替え確認まで含めるのは、生成コードと supportedLocales の動きを見やすくするためです。
2. 先に l10n の流れを掴む
今回の流れは次の通りです。
flowchart LR
A["pubspec.yaml で l10n を有効化"] --> B["l10n.yaml と app_ja.arb / app_en.arb を用意"]
B --> C["flutter gen-l10n"]
C --> D["lib/l10n/app_localizations.dart を生成"]
D --> E["MaterialApp に delegate と supportedLocales を設定"]
E --> F["Widget から AppLocalizations.of(context) を参照"]
F --> G["タイトル / ラベル / 件数 / バリデーション文言を表示"]
役割は 4 つに分けると追いやすくなります。
pubspec.yaml: l10n 関連の依存関係と生成スイッチl10n.yaml: どの ARB を基準にし、どこへ生成コードを出すかを決める設定arb: 翻訳データの正本MaterialApp設定: どのロケールを使えるか、いま何語で描画するかを決める場所
ここで混ざりやすいのが supportedLocales と locale の違いです。
supportedLocales: このアプリが対応している言語一覧locale: いま強制的に使う言語。nullなら端末設定に従う
先にこの役割を切り分けておくと、あとで「日本語文言はあるのに切り替わらない」状態を見たとき、確認箇所を絞りやすくなります。
3. 動かす環境を整える
3-1. Flutter プロジェクトを作成する
環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
次のコマンドでプロジェクトを作成します。
flutter create my_l10n_app
cd my_l10n_app
3-2. エミュレーターを起動する
利用可能なエミュレーター一覧を確認します。
flutter emulators
表示された ID を指定して起動します。
flutter emulators --launch <emulator_id>
3-3. pubspec.yaml を更新する
アプリコードから intl を直接呼ぶ必要はありませんが、現行 Flutter の gen-l10n では依存関係として追加しておく必要があります。そのため、DartPad ではなく Flutter プロジェクト上で確認します。
ここで追加するのは 3 箇所です。flutter create 直後の pubspec.yaml を丸ごと書き換える必要はありません。
dependencies:のflutter:直下にflutter_localizationsを追加するdependencies:にintl: anyを追加するflutter:セクションのuses-material-design: trueと同じ階層にgenerate: trueを追加する
手元で生成されたコメント行、description、environment.sdk、flutter_lints の値はそのままで構いません。更新後に確認したい箇所は次のとおりです。
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: any
cupertino_icons: ^1.0.8
flutter:
uses-material-design: true
generate: true
コードのポイント
① flutter_localizations と intl を dependencies に入れる
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: any
cupertino_icons: ^1.0.8
flutter_localizations は、日付ピッカーや一部の Material 文言まで切り替えるために必要です。intl は生成された l10n コードが内部で利用する依存関係で、plural やプレースホルダーを使う最小構成でも揃えておくほうが確実です。
② generate: true が生成コードのスイッチになる
flutter:
uses-material-design: true
generate: true
generate: true を入れると、l10n.yaml と lib/l10n/*.arb をもとに lib/l10n/app_localizations.dart が生成されます。ARB を保存しても import 先が見つからないときは、まずこのスイッチを確認するのが最短です。
3-4. l10n.yaml を作成する
プロジェクトルートに l10n.yaml を作成します。
このファイルは、gen-l10n がどの ARB を入力として読み、どこへ生成コードを書き出すかを決める設定です。今回は lib/l10n/app_localizations.dart を生成する最小構成にします。
テンプレート ARB には英語版を使うのが Flutter 標準の流れなので、ここでは app_en.arb を指定します。
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
この時点では app_en.arb がまだなくても構いません。次の 4-2 で作成します。
pubspec.yaml と l10n.yaml を保存したら、次のコマンドで依存関係を解決します。
flutter pub get
4. arb を作成する
4-1. lib/l10n/app_ja.arb を作成する
lib/l10n/app_ja.arb は次の内容で作成します。
このファイルは、日本語で表示する文言とプレースホルダー定義をまとめる ARB です。注目点は、固定文字列だけでなく、引数付き文言と plural も最初から同じ形式で持っていることです。
{
"@@locale": "ja",
"appTitle": "倉庫アプリサンプル",
"screenTitle": "出荷状況の確認",
"description": "タイトル、件数表示、保存メッセージをローカライズします。",
"languageModeLabel": "表示言語",
"systemLocaleOption": "端末設定に従う",
"japaneseOption": "日本語",
"englishOption": "英語",
"currentLocaleLabel": "現在のロケール: {locale}",
"@currentLocaleLabel": {
"placeholders": {
"locale": {
"type": "String"
}
}
},
"nameLabel": "作業者名",
"nameHint": "例: 佐藤",
"requiredName": "作業者名を入力してください。",
"welcomeMessage": "{name} さん、ようこそ。",
"@welcomeMessage": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pendingCount": "{count, plural, =0{未処理の出荷はありません。} =1{未処理の出荷が1件あります。} other{未処理の出荷が{count}件あります。}}",
"@pendingCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"saveButton": "下書きを保存",
"savedMessage": "{language} で保存しました。",
"@savedMessage": {
"placeholders": {
"language": {
"type": "String"
}
}
},
"countSelectorLabel": "未処理件数"
}
コードのポイント
① プレースホルダー付き文言は本体とメタ情報をセットで持つ
"welcomeMessage": "{name} さん、ようこそ。",
"@welcomeMessage": {
"placeholders": {
"name": {
"type": "String"
}
}
}
welcomeMessage の本文だけでなく、@welcomeMessage 側に引数型まで書いておくことで生成コードから安全に呼べます。ログインユーザー名や受注番号のように、後から動的値を差し込みたい場面でも同じ形式を使えます。
② 件数文言は plural で定義しておく
"pendingCount": "{count, plural, =0{未処理の出荷はありません。} =1{未処理の出荷が1件あります。} other{未処理の出荷が{count}件あります。}}",
"@pendingCount": {
"placeholders": {
"count": {
"type": "int"
}
}
}
plural は、件数に応じて文言を出し分ける仕組みです。この例では =0 が 0 件、=1 が 1 件、other がそれ以外を表します。
日本語だけを見ると文字列連結でも書けそうですが、ここで plural へ寄せておくと英語版を追加したときに構造を変えずに済みます。件数表示を最初からローカライズ対象として扱うための土台です。
4-2. lib/l10n/app_en.arb を作成する
lib/l10n/app_en.arb は次の内容で作成します。
このファイルは、日本語版と同じキー構成で英語文言を定義する ARB です。注目点は、キー名とプレースホルダー構造を揃えたまま、英語側だけ plural ルールや語順を自然な形へ差し替えていることです。
{
"@@locale": "en",
"appTitle": "Warehouse Sample",
"screenTitle": "Shipment status",
"description": "Localize the title, count summary, and save message.",
"languageModeLabel": "Language",
"systemLocaleOption": "Use system setting",
"japaneseOption": "Japanese",
"englishOption": "English",
"currentLocaleLabel": "Current locale: {locale}",
"@currentLocaleLabel": {
"placeholders": {
"locale": {
"type": "String"
}
}
},
"nameLabel": "Operator name",
"nameHint": "Example: Sato",
"requiredName": "Please enter an operator name.",
"welcomeMessage": "Hello, {name}.",
"@welcomeMessage": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"pendingCount": "{count, plural, =0{No shipments are waiting.} =1{1 shipment is waiting.} other{{count} shipments are waiting.}}",
"@pendingCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"saveButton": "Save draft",
"savedMessage": "Saved in {language}.",
"@savedMessage": {
"placeholders": {
"language": {
"type": "String"
}
}
},
"countSelectorLabel": "Pending count"
}
コードのポイント
① 日本語版と同じキー構成を保っている
{
"@@locale": "en",
"appTitle": "Warehouse Sample",
"screenTitle": "Shipment status",
"description": "Localize the title, count summary, and save message.",
"languageModeLabel": "Language",
キー名を日本語版と揃えているため、Widget 側は AppLocalizations の呼び出し方を変えずに済みます。言語ごとの差は ARB に閉じ込め、Dart 側は同じ API で使える形にしているのがポイントです。
② plural と引数文言は言語ごとの自然な語順へ任せる
"pendingCount": "{count, plural, =0{No shipments are waiting.} =1{1 shipment is waiting.} other{{count} shipments are waiting.}}",
"savedMessage": "Saved in {language}.",
英語では 1 shipment と 3 shipments のように件数で形が変わるため、plural を通す価値が日本語以上に大きくなります。固定文言、引数付き文言、plural の 3 種類を先に揃えておくと、あとから String の手書き連結へ戻りにくくなります。
4-3. 生成コードを作成する
ARB を 2 つ保存したら、次のコマンドで生成コードを作成します。
flutter gen-l10n
実行後に lib/l10n/app_localizations.dart が生成されていれば準備完了です。Target of URI doesn't exist が出る場合は、まずこのコマンドが通っているかと、lib/main.dart 側が package:flutter_gen/... ではなく l10n/app_localizations.dart を import しているかを確認してください。
5. lib/main.dart に最小サンプルを作る
flutter gen-l10n を実行し、lib/l10n/app_localizations.dart が生成されたら、lib/main.dart は次の内容で作成します。
このファイルは、生成された AppLocalizations を画面で実際に使う最小サンプルです。注目点は、MaterialApp 側で利用可能な言語を宣言し、画面側では引数付き文言、plural、バリデーションまで同じ l10n API から読んでいることです。
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';
void main() {
runApp(const MyApp());
}
enum LanguageMode {
system,
japanese,
english,
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
LanguageMode _languageMode = LanguageMode.system;
Locale? get _locale {
switch (_languageMode) {
case LanguageMode.system:
return null;
case LanguageMode.japanese:
return const Locale('ja');
case LanguageMode.english:
return const Locale('en');
}
}
void _handleLanguageChanged(LanguageMode? mode) {
if (mode == null) {
return;
}
setState(() {
_languageMode = mode;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
onGenerateTitle: (BuildContext context) {
return AppLocalizations.of(context)!.appTitle;
},
locale: _locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: LocalizationDemoPage(
languageMode: _languageMode,
onLanguageChanged: _handleLanguageChanged,
),
);
}
}
class LocalizationDemoPage extends StatefulWidget {
const LocalizationDemoPage({
super.key,
required this.languageMode,
required this.onLanguageChanged,
});
final LanguageMode languageMode;
final ValueChanged<LanguageMode?> onLanguageChanged;
@override
State<LocalizationDemoPage> createState() => _LocalizationDemoPageState();
}
class _LocalizationDemoPageState extends State<LocalizationDemoPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _nameController =
TextEditingController(text: '佐藤');
int _pendingCount = 3;
String _lastSavedMessage = '-';
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
void _save(AppLocalizations l10n) {
final FormState? form = _formKey.currentState;
if (form == null || !form.validate()) {
return;
}
final Locale activeLocale = Localizations.localeOf(context);
final String languageLabel = _languageLabel(l10n, activeLocale);
final String message = l10n.savedMessage(languageLabel);
setState(() {
_lastSavedMessage = message;
});
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
String _languageLabel(AppLocalizations l10n, Locale locale) {
switch (locale.languageCode) {
case 'ja':
return l10n.japaneseOption;
case 'en':
return l10n.englishOption;
default:
return locale.languageCode;
}
}
@override
Widget build(BuildContext context) {
final AppLocalizations l10n = AppLocalizations.of(context)!;
final Locale activeLocale = Localizations.localeOf(context);
return Scaffold(
appBar: AppBar(
title: Text(l10n.screenTitle),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
_SectionCard(
title: l10n.languageModeLabel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SegmentedButton<LanguageMode>(
segments: <ButtonSegment<LanguageMode>>[
ButtonSegment<LanguageMode>(
value: LanguageMode.system,
label: Text(l10n.systemLocaleOption),
),
ButtonSegment<LanguageMode>(
value: LanguageMode.japanese,
label: Text(l10n.japaneseOption),
),
ButtonSegment<LanguageMode>(
value: LanguageMode.english,
label: Text(l10n.englishOption),
),
],
selected: <LanguageMode>{widget.languageMode},
onSelectionChanged: (Set<LanguageMode> selection) {
widget.onLanguageChanged(selection.firstOrNull);
},
),
const SizedBox(height: 12),
Text(l10n.currentLocaleLabel(activeLocale.languageCode)),
],
),
),
const SizedBox(height: 16),
_SectionCard(
title: l10n.screenTitle,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(l10n.description),
const SizedBox(height: 12),
Form(
key: _formKey,
child: TextFormField(
controller: _nameController,
onChanged: (String _) {
setState(() {});
},
decoration: InputDecoration(
labelText: l10n.nameLabel,
hintText: l10n.nameHint,
border: const OutlineInputBorder(),
),
validator: (String? value) {
if (value == null || value.trim().isEmpty) {
return l10n.requiredName;
}
return null;
},
),
),
const SizedBox(height: 12),
Text(l10n.welcomeMessage(_nameController.text.trim())),
],
),
),
const SizedBox(height: 16),
_SectionCard(
title: l10n.countSelectorLabel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
DropdownButton<int>(
value: _pendingCount,
items: const <DropdownMenuItem<int>>[
DropdownMenuItem<int>(
value: 0,
child: Text('0'),
),
DropdownMenuItem<int>(
value: 1,
child: Text('1'),
),
DropdownMenuItem<int>(
value: 3,
child: Text('3'),
),
DropdownMenuItem<int>(
value: 10,
child: Text('10'),
),
],
onChanged: (int? value) {
if (value == null) {
return;
}
setState(() {
_pendingCount = value;
});
},
),
const SizedBox(height: 8),
Text(l10n.pendingCount(_pendingCount)),
],
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => _save(l10n),
child: Text(l10n.saveButton),
),
const SizedBox(height: 12),
Text(_lastSavedMessage),
],
),
),
);
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({
required this.title,
required this.child,
});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
child,
],
),
),
);
}
}
extension<T> on Set<T> {
T? get firstOrNull {
if (isEmpty) {
return null;
}
return first;
}
}
コードのポイント
① MaterialApp 側で対応言語と delegate を宣言する
return MaterialApp(
onGenerateTitle: (BuildContext context) {
return AppLocalizations.of(context)!.appTitle;
},
locale: _locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
MaterialApp 側の責務は、どの言語を使えるかと、どの delegate を有効にするかを宣言することです。ここが抜けると、自分で書いた文字列だけでなく Material Components 側の文言も期待どおりに切り替わりません。
② 保存メッセージは引数付き文言から組み立てる
void _save(AppLocalizations l10n) {
final FormState? form = _formKey.currentState;
if (form == null || !form.validate()) {
return;
}
final Locale activeLocale = Localizations.localeOf(context);
final String languageLabel = _languageLabel(l10n, activeLocale);
final String message = l10n.savedMessage(languageLabel);
setState(() {
_lastSavedMessage = message;
});
保存完了メッセージを l10n.savedMessage(languageLabel) から組み立てているため、文言の語順を言語ごとに ARB 側へ任せられます。固定文言だけでなく引数付き文言も同じ API へ寄せると、後続の記事でログインユーザー名や受注番号を差し込む場面へそのまま広げられます。
③ バリデーションも同じ l10n API から返す
Form(
key: _formKey,
child: TextFormField(
controller: _nameController,
onChanged: (String _) {
setState(() {});
},
decoration: InputDecoration(
labelText: l10n.nameLabel,
hintText: l10n.nameHint,
border: const OutlineInputBorder(),
),
validator: (String? value) {
if (value == null || value.trim().isEmpty) {
return l10n.requiredName;
}
入力ラベル、ヒント、未入力エラーまで AppLocalizations から返すため、表示言語の切り替え範囲がフォーム全体で揃います。件数表示の plural と同じく、画面側は l10n API を呼ぶだけにしておくと、文言の置き場を作り直さずに済みます。
7. 起動して確認する
ここまでできたら、次のコマンドで起動します。
flutter run
起動後は次の順で確認すると追いやすくなります。
Systemのまま起動し、端末設定に応じた言語になるか確認する日本語に切り替え、タイトル、入力ラベル、保存ボタンが日本語になるか確認する英語に切り替え、plural と Snackbar が英語に変わるか確認する- 名前を空にして保存し、バリデーションメッセージだけも切り替わるか確認する
AppLocalizations の import でエラーが出るとき
生成コードがまだ無い状態で flutter analyze を実行すると、次のように出ます。
error - Target of URI doesn't exist: 'l10n/app_localizations.dart' - lib\main.dart:3:8 - uri_does_not_exist
error - Undefined name 'AppLocalizations' - lib\main.dart:13:31 - undefined_identifier
error - Undefined name 'AppLocalizations' - lib\main.dart:14:25 - undefined_identifier
**Target of URI doesn't exist が本体で、Undefined name 'AppLocalizations' はその巻き添えです。**import が解決できないので、そこから来る名前が全部未定義になります。下の Undefined name を1件ずつ追いかけないでください。
見直す順序は次のとおりです。
pubspec.yamlのgenerate: true(flutter:セクション側。dependencies:ではない)l10n.yamlがプロジェクトルートにあるかlib/l10n/*.arbを保存したかflutter pub getを実行したか
generate: true が入っていれば、flutter pub get の時点で lib/l10n/app_localizations.dart が生成されます。flutter gen-l10n を明示的に叩かなくても構いません。逆に言うと、generate: true を書き忘れていると、ARB をいくら保存してもファイルは生成されません。最初に疑うのはここです。
また、現行 Flutter では package:flutter_gen/... ではなく l10n/app_localizations.dart を import します。古い記事のとおりに package:flutter_gen/gen_l10n/app_localizations.dart と書くと、同じ Target of URI doesn't exist になります。
以下は、英語を既定にした Android Emulator での確認例です。切り替え直後の状態を載せているので、Snackbar とバリデーションは保存操作と未入力時の確認で補ってください。
System を選び、端末設定に従って英語表示になった状態です。
日本語 を選び、タイトル、説明文、件数表示が日本語へ切り替わった状態です。
英語 を選び、アプリ側の言語設定で英語へ切り替えた状態です。
8. 最初に localize する対象を絞る
最初の段階では、画面内のすべての文字列を一度に移す必要はありません。優先度を付けるなら次の順が目安になります。
- AppBar タイトルや画面見出し
- ボタン文言
- 入力ラベルとバリデーションメッセージ
- 件数や状態の要約文
この順に寄せておくと、次に Flutterでgo_routerの認証ガードを実装する(redirect最小構成) のログイン画面や、FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) のエラー表示へ広げるとき、文言の置き場を作り直さずに済みます。
9. まとめ
Flutter の日本語化は、arb に文言を置き、AppLocalizations を経由して Widget から呼ぶ流れを最初に固めると進めやすくなります。supportedLocales は対応言語の一覧、locale は現在使う言語、validator や plural も含めて AppLocalizations へ寄せる。この形を先に作っておけば、設定画面、ログイン画面、フォーム、CRUD へ進んでも文言の置き場がぶれません。
次に見返しやすい既存記事は次の 3 本です。