Flutter Localization Without Writing It Twice
Flutter SDK has you write localization twice, once through ARB and once through ICU message. Here is how to do it in a smarter way and have it generated, even the AppLocalizations class.
Why Do This?
Flutter Docs confuses most people as it talks about the gen_l10n tool along with the intl tools in the same damn page as for most of us gen_l10n is actually somewhat legacy as no one chooses to do it that way anymore as we all want smart placeholder superpowers!
What we do instead is that we use a third party Flutter Community package tool that plugs into the gen_l10n tool and uses the build runner to auto generate the message and AppLocalization classes from the ARB files that we define.
Since this more flutter power approach allows the use of placeholders with more power, I first need to explain how the ICU message API maps through the ARB file constructs we have to write.
ARB File Format
Before I get to the placeholders, let me cover the global variables in the ARB format. The ARB format spec can be found here in the VSCode extension docs:
https://github.com/google/arb-editor
Globals are always denoted by two at-symbols. Flutter intl plugins will have you define at least the locale global, here let me show you a snippet from my own ARB files:
{ | |
"@@locale": "en", | |
"title": "App", | |
"@title": { | |
"description": "Title for the App", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"listTileTitle": "SampleItem {value}", | |
"@listTileTitle": { | |
"description": "ListTile title", | |
"type": "text", | |
"placeholders": { | |
"value": { | |
"type": "int" | |
} | |
} | |
}, | |
"sampleItemListViewTitle": "Sample Items", | |
"@sampleItemListViewTitle": { | |
"description": "Sample Item List View Title", | |
"type": "text", | |
"placeholders": {} | |
}, |
So basically placeholders work like this;
{ | |
"@@locale": "en", | |
"title": "App", | |
"@title": { | |
"description": "Title for the App", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"listTileTitle": "SampleItem {value}", | |
"@listTileTitle": { | |
"description": "ListTile title", | |
"type": "text", | |
"placeholders": { | |
"value": { | |
"type": "int" | |
} | |
} | |
}, | |
"sampleItemListViewTitle": "Sample Items", | |
"@sampleItemListViewTitle": { | |
"description": "Sample Item List View Title", | |
"type": "text", | |
"placeholders": {} | |
}, |
Placeholders come in the form of value, gender, count, etc. with all placeholders taking at minimum a type and maximum a type and format.
Now, let me show you the superpower of using Localizely's intl_utils to generate both the message classes and the AppLocalization classes.
Using Loclizely's intl_utils Package
Localizely's intl_utils package is here:
https://pub.dev/packages/intl_utils
The minimum installation is these dependencies in the pubspec YAML file:
dependencies: | |
flutter: | |
sdk: flutter | |
flutter_localizations: | |
sdk: flutter | |
# localizations | |
intl: ^0.18.1 | |
intl_utils: ^2.8.5 |
Then at the end of your pubspec YAML file:
# intl stuff via intl utils | |
flutter_intl: | |
enabled: true | |
class_name: CustomAppLocalizations | |
main_locale: en | |
arb_dir: lib/src/localization | |
output_dir: lib/src/localization |
Now, let's pretend we are localizing the skeleton template app with both English and German. Then our intl en ARB file would be:
{ | |
"@@locale": "en", | |
"title": "App", | |
"@title": { | |
"description": "Title for the App", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"listTileTitle": "SampleItem {value}", | |
"@listTileTitle": { | |
"description": "ListTile title", | |
"type": "text", | |
"placeholders": { | |
"value": { | |
"type": "int" | |
} | |
} | |
}, | |
"sampleItemListViewTitle": "Sample Items", | |
"@sampleItemListViewTitle": { | |
"description": "Sample Item List View Title", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"sampleItemDetailsViewTitle": "Item Details", | |
"@sampleItemDetailsViewTitle": { | |
"description": "Sample Item Detials view Title", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"settingsViewTitle": "Settings", | |
"@settingsViewTitle": { | |
"description": "Settings View Title", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"settingsSystemTheme": "System Theme", | |
"@settingsSystemTheme": { | |
"description": "Settings System Theme", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"settingsLightTheme": "Light Theme", | |
"@settingsLightTheme": { | |
"description": "Settings Light Theme", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"settingsDarkTheme": "Dark Theme", | |
"@settingsDarkTheme": { | |
"description": "Settings Dark Theme", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"sampleItemDetailsViewDetail": "More Information Here", | |
"@sampleItemDetailsViewDetail": { | |
"description": "Sample Item Detials View Detail", | |
"type": "text", | |
"placeholders": {} | |
} | |
} |
And, the German ARB file would be:
{ | |
"@@locale": "de", | |
"title": "App", | |
"@title": { | |
"description": "Title for the App", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"listTileTitle": "Musterartikel {value}", | |
"@listTileTitle": { | |
"description": "ListTile title", | |
"type": "text", | |
"placeholders": { | |
"value": { | |
"type": "int" | |
} | |
} | |
}, | |
"sampleItemListViewTitle": "Beispielartikel", | |
"@sampleItemListViewTitle": { | |
"description": "Sample Item List View Title", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"sampleItemDetailsViewTitle": "Artikeldetails", | |
"@sampleItemDetailsViewTitle": { | |
"description": "Sample Item Detials view Title", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"settingsViewTitle": "Einstellungen", | |
"@settingsViewTitle": { | |
"description": "Settings View Title", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"settingsSystemTheme": "System Thema", | |
"@settingsSystemTheme": { | |
"description": "Settings System Theme", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"settingsLightTheme": "Licht Thema", | |
"@settingsLightTheme": { | |
"description": "Settings Light Theme", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"settingsDarkTheme": "Dunkles Thema", | |
"@settingsDarkTheme": { | |
"description": "Settings Dark Theme", | |
"type": "text", | |
"placeholders": {} | |
}, | |
"sampleItemDetailsViewDetail": "Weitere Informationen Hier", | |
"@sampleItemDetailsViewDetail": { | |
"description": "Sample Item Detials View Detail", | |
"type": "text", | |
"placeholders": {} | |
} | |
} |
Now, before I show what is generated and show how to use the generated AppLocalisiotns class let me clue you in another trick. If I pre-determine what I need for in screens and create the ARB file then add the intl and intl_utils dependencies it will auto generate the files when I open the project that first time. If you forget to do it that way then you have to run this command in the terminal:
flutter pub run intl_utils:generate |
Now, let me show you what it generates and how the generated AppLocalizations class works and how to use it in the skeleton app.
How The Generated Intl Utils Classes Work And How To Use AppLocalizations Class
So let me start out by showing the generated AppLocalizaations class. That class usually gets generated as part of the l10n dart file:
// GENERATED CODE - DO NOT MODIFY BY HAND | |
import 'package:flutter/material.dart'; | |
import 'package:intl/intl.dart'; | |
import 'intl/messages_all.dart'; | |
// ************************************************************************** | |
// Generator: Flutter Intl IDE plugin | |
// Made by Localizely | |
// ************************************************************************** | |
// ignore_for_file: non_constant_identifier_names, lines_longer_than_80_chars | |
// ignore_for_file: join_return_with_assignment, prefer_final_in_for_each | |
// ignore_for_file: avoid_redundant_argument_values, avoid_escaping_inner_quotes | |
class CustomAppLocalizations { | |
CustomAppLocalizations(); | |
static CustomAppLocalizations? _current; | |
static CustomAppLocalizations get current { | |
assert(_current != null, | |
'No instance of CustomAppLocalizations was loaded. Try to initialize the CustomAppLocalizations delegate before accessing CustomAppLocalizations.current.'); | |
return _current!; | |
} | |
static const AppLocalizationDelegate delegate = AppLocalizationDelegate(); | |
static Future<CustomAppLocalizations> load(Locale locale) { | |
final name = (locale.countryCode?.isEmpty ?? false) | |
? locale.languageCode | |
: locale.toString(); | |
final localeName = Intl.canonicalizedLocale(name); | |
return initializeMessages(localeName).then((_) { | |
Intl.defaultLocale = localeName; | |
final instance = CustomAppLocalizations(); | |
CustomAppLocalizations._current = instance; | |
return instance; | |
}); | |
} | |
static CustomAppLocalizations of(BuildContext context) { | |
final instance = CustomAppLocalizations.maybeOf(context); | |
assert(instance != null, | |
'No instance of CustomAppLocalizations present in the widget tree. Did you add CustomAppLocalizations.delegate in localizationsDelegates?'); | |
return instance!; | |
} | |
static CustomAppLocalizations? maybeOf(BuildContext context) { | |
return Localizations.of<CustomAppLocalizations>( | |
context, CustomAppLocalizations); | |
} | |
/// `App` | |
String get title { | |
return Intl.message( | |
'App', | |
name: 'title', | |
desc: 'Title for the App', | |
args: [], | |
); | |
} | |
/// `SampleItem {value}` | |
String listTileTitle(int value) { | |
return Intl.message( | |
'SampleItem $value', | |
name: 'listTileTitle', | |
desc: 'ListTile title', | |
args: [value], | |
); | |
} | |
/// `Sample Items` | |
String get sampleItemListViewTitle { | |
return Intl.message( | |
'Sample Items', | |
name: 'sampleItemListViewTitle', | |
desc: 'Sample Item List View Title', | |
args: [], | |
); | |
} | |
/// `Item Details` | |
String get sampleItemDetailsViewTitle { | |
return Intl.message( | |
'Item Details', | |
name: 'sampleItemDetailsViewTitle', | |
desc: 'Sample Item Detials view Title', | |
args: [], | |
); | |
} | |
/// `Settings` | |
String get settingsViewTitle { | |
return Intl.message( | |
'Settings', | |
name: 'settingsViewTitle', | |
desc: 'Settings View Title', | |
args: [], | |
); | |
} | |
/// `System Theme` | |
String get settingsSystemTheme { | |
return Intl.message( | |
'System Theme', | |
name: 'settingsSystemTheme', | |
desc: 'Settings System Theme', | |
args: [], | |
); | |
} | |
/// `Light Theme` | |
String get settingsLightTheme { | |
return Intl.message( | |
'Light Theme', | |
name: 'settingsLightTheme', | |
desc: 'Settings Light Theme', | |
args: [], | |
); | |
} | |
/// `Dark Theme` | |
String get settingsDarkTheme { | |
return Intl.message( | |
'Dark Theme', | |
name: 'settingsDarkTheme', | |
desc: 'Settings Dark Theme', | |
args: [], | |
); | |
} | |
/// `More Information Here` | |
String get sampleItemDetailsViewDetail { | |
return Intl.message( | |
'More Information Here', | |
name: 'sampleItemDetailsViewDetail', | |
desc: 'Sample Item Detials View Detail', | |
args: [], | |
); | |
} | |
} | |
class AppLocalizationDelegate | |
extends LocalizationsDelegate<CustomAppLocalizations> { | |
const AppLocalizationDelegate(); | |
List<Locale> get supportedLocales { | |
return const <Locale>[ | |
Locale.fromSubtags(languageCode: 'en'), | |
Locale.fromSubtags(languageCode: 'de'), | |
]; | |
} | |
@override | |
bool isSupported(Locale locale) => _isSupported(locale); | |
@override | |
Future<CustomAppLocalizations> load(Locale locale) => | |
CustomAppLocalizations.load(locale); | |
@override | |
bool shouldReload(AppLocalizationDelegate old) => false; | |
bool _isSupported(Locale locale) { | |
for (var supportedLocale in supportedLocales) { | |
if (supportedLocale.languageCode == locale.languageCode) { | |
return true; | |
} | |
} | |
return false; | |
} | |
} |
Two things to notice. One, the function methods returning a Intl-dot-message form are always embedded. Second, that plugins into the all messages class file:
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart | |
// This is a library that looks up messages for specific locales by | |
// delegating to the appropriate library. | |
// Ignore issues from commonly used lints in this file. | |
// ignore_for_file:implementation_imports, file_names, unnecessary_new | |
// ignore_for_file:unnecessary_brace_in_string_interps, directives_ordering | |
// ignore_for_file:argument_type_not_assignable, invalid_assignment | |
// ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases | |
// ignore_for_file:comment_references | |
import 'dart:async'; | |
import 'package:flutter/foundation.dart'; | |
import 'package:intl/intl.dart'; | |
import 'package:intl/message_lookup_by_library.dart'; | |
import 'package:intl/src/intl_helpers.dart'; | |
import 'messages_de.dart' as messages_de; | |
import 'messages_en.dart' as messages_en; | |
typedef Future<dynamic> LibraryLoader(); | |
Map<String, LibraryLoader> _deferredLibraries = { | |
'de': () => new SynchronousFuture(null), | |
'en': () => new SynchronousFuture(null), | |
}; | |
MessageLookupByLibrary? _findExact(String localeName) { | |
switch (localeName) { | |
case 'de': | |
return messages_de.messages; | |
case 'en': | |
return messages_en.messages; | |
default: | |
return null; | |
} | |
} | |
/// User programs should call this before using [localeName] for messages. | |
Future<bool> initializeMessages(String localeName) { | |
var availableLocale = Intl.verifiedLocale( | |
localeName, (locale) => _deferredLibraries[locale] != null, | |
onFailure: (_) => null); | |
if (availableLocale == null) { | |
return new SynchronousFuture(false); | |
} | |
var lib = _deferredLibraries[availableLocale]; | |
lib == null ? new SynchronousFuture(false) : lib(); | |
initializeInternalMessageLookup(() => new CompositeMessageLookup()); | |
messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor); | |
return new SynchronousFuture(true); | |
} | |
bool _messagesExistFor(String locale) { | |
try { | |
return _findExact(locale) != null; | |
} catch (e) { | |
return false; | |
} | |
} | |
MessageLookupByLibrary? _findGeneratedMessagesFor(String locale) { | |
var actualLocale = | |
Intl.verifiedLocale(locale, _messagesExistFor, onFailure: (_) => null); | |
if (actualLocale == null) return null; | |
return _findExact(actualLocale); | |
} |
And that is where the locale lookup is performed to find the locale specified message in the generated message locale dart file:
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart | |
// This is a library that provides messages for a en locale. All the | |
// messages from the main program should be duplicated here with the same | |
// function name. | |
// Ignore issues from commonly used lints in this file. | |
// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new | |
// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering | |
// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases | |
// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes | |
// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes | |
import 'package:intl/intl.dart'; | |
import 'package:intl/message_lookup_by_library.dart'; | |
final messages = new MessageLookup(); | |
typedef String MessageIfAbsent(String messageStr, List<dynamic> args); | |
class MessageLookup extends MessageLookupByLibrary { | |
String get localeName => 'en'; | |
static String m0(value) => "SampleItem ${value}"; | |
final messages = _notInlinedMessages(_notInlinedMessages); | |
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{ | |
"listTileTitle": m0, | |
"sampleItemDetailsViewDetail": | |
MessageLookupByLibrary.simpleMessage("More Information Here"), | |
"sampleItemDetailsViewTitle": | |
MessageLookupByLibrary.simpleMessage("Item Details"), | |
"sampleItemListViewTitle": | |
MessageLookupByLibrary.simpleMessage("Sample Items"), | |
"settingsDarkTheme": MessageLookupByLibrary.simpleMessage("Dark Theme"), | |
"settingsLightTheme": | |
MessageLookupByLibrary.simpleMessage("Light Theme"), | |
"settingsSystemTheme": | |
MessageLookupByLibrary.simpleMessage("System Theme"), | |
"settingsViewTitle": MessageLookupByLibrary.simpleMessage("Settings"), | |
"title": MessageLookupByLibrary.simpleMessage("App") | |
}; | |
} |
The lookup message class by locale is just basically a class that has set of strings and when placeholders are used also a map of string and function.
Now to use the AppLocalizations class let's start with the MaterialApp declaration in the MyApp class:
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/material.dart'; | |
import 'package:flutter_localizations/flutter_localizations.dart'; | |
import 'package:localization_demo/src/localization/l10n.dart'; | |
import 'package:localization_demo/src/sample_feature/sample_item_details_view.dart'; | |
import 'package:localization_demo/src/sample_feature/sample_item_list_view.dart'; | |
import 'package:localization_demo/src/settings/settings_controller.dart'; | |
import 'package:localization_demo/src/settings/settings_view.dart'; | |
/// The Widget that configures your application. | |
class MyApp extends StatelessWidget { | |
const MyApp({ | |
super.key, | |
required this.settingsController, | |
}); | |
final SettingsController settingsController; | |
@override | |
Widget build(BuildContext context) { | |
// Glue the SettingsController to the MaterialApp. | |
// | |
// The ListenableBuilder Widget listens to the SettingsController for changes. | |
// Whenever the user updates their settings, the MaterialApp is rebuilt. | |
return ListenableBuilder( | |
listenable: settingsController, | |
builder: (BuildContext context, Widget? child,) { | |
return MaterialApp(onGenerateRoute: (RouteSettings routeSettings) { | |
return MaterialPageRoute<void>( | |
builder: (BuildContext context) { | |
switch (routeSettings.name) { | |
case SettingsView.routeName: return SettingsView(controller: settingsController); | |
case SampleItemDetailsView.routeName: return const SampleItemDetailsView(); | |
case SampleItemListView.routeName: default: return const SampleItemListView(); | |
} | |
}, | |
settings: routeSettings, | |
); | |
}, | |
onGenerateTitle: (BuildContext context) => CustomAppLocalizations.of(context).title, | |
theme: ThemeData(), | |
darkTheme: ThemeData.dark(), | |
themeMode: settingsController.themeMode, | |
localizationsDelegates: const [ | |
CustomAppLocalizations.delegate, | |
GlobalMaterialLocalizations.delegate, | |
GlobalWidgetsLocalizations.delegate, | |
GlobalCupertinoLocalizations.delegate, | |
], | |
supportedLocales: CustomAppLocalizations.delegate.supportedLocales, | |
debugShowCheckedModeBanner: false, | |
restorationScopeId: 'app', | |
); | |
}, | |
); | |
} | |
} |
Notice we have to import the l10n dart file from the localization subfolder. My AppLocalizations class is named CustomAppLocalizations and in the localizationsDelegates I specify the CustomAppLocalizations delegate. Then in the supported locales MaterialApp parameter I re-use the CustomAppLocalizations delegate to call the supported locales. And, of course for title generation I use the CustomAppLocalizations of context method to grab the titile via the title getter.
Now on to some screens, first the settings view screen:
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/material.dart'; | |
import 'package:localization_demo/src/localization/l10n.dart'; | |
import 'package:localization_demo/src/settings/settings_controller.dart'; | |
/// Displays the various settings that can be customized by the user. | |
/// | |
/// When a user changes a setting, the SettingsController is updated and | |
/// Widgets that listen to the SettingsController are rebuilt. | |
class SettingsView extends StatelessWidget { | |
const SettingsView({ | |
super.key, | |
required this.controller, | |
}); | |
static const routeName = '/settings'; | |
final SettingsController controller; | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar( | |
title: const Text('Settings'), | |
), | |
body: Padding( | |
padding: const EdgeInsets.all(16), | |
// Glue the SettingsController to the theme selection DropdownButton. | |
// | |
// When a user selects a theme from the dropdown list, the | |
// SettingsController is updated, which rebuilds the MaterialApp. | |
child: DropdownButton<ThemeMode>( | |
items: [ | |
DropdownMenuItem(value: ThemeMode.system, child: Text(CustomAppLocalizations.of(context).settingsSystemTheme),), | |
DropdownMenuItem(value: ThemeMode.light, child: Text(CustomAppLocalizations.of(context).settingsLightTheme),), | |
DropdownMenuItem(value: ThemeMode.dark, child: Text(CustomAppLocalizations.of(context).settingsDarkTheme),), | |
], | |
value: controller.themeMode, | |
onChanged: controller.updateThemeMode, | |
), | |
), | |
); | |
} | |
} |
Again just using the CustomAppLocalizations of context method to grab the right getter to return the locale string. And that is with the l10n dart import from the localization subfolder.
Now let me show the sample item list view which will use the placeholder:
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/material.dart'; | |
import 'package:localization_demo/src/localization/l10n.dart'; | |
import 'package:localization_demo/src/sample_feature/sample_item.dart'; | |
import 'package:localization_demo/src/sample_feature/sample_item_details_view.dart'; | |
import 'package:localization_demo/src/settings/settings_view.dart'; | |
/// Displays a list of SampleItems. | |
class SampleItemListView extends StatelessWidget { | |
const SampleItemListView({ | |
super.key, | |
this.items = const [SampleItem(1), SampleItem(2), SampleItem(3),], | |
}); | |
static const routeName = '/'; | |
final List<SampleItem> items; | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar( | |
title:Text(CustomAppLocalizations.of(context).sampleItemListViewTitle), | |
actions: [ | |
IconButton(onPressed: () {Navigator.restorablePushNamed(context, SettingsView.routeName,);}, | |
icon: const Icon(Icons.settings),), | |
], | |
), | |
// To work with lists that may contain a large number of items, it’s best | |
// to use the ListView.builder constructor. | |
// | |
// In contrast to the default ListView constructor, which requires | |
// building all Widgets up front, the ListView.builder constructor lazily | |
// builds Widgets as they’re scrolled into view. | |
body: ListView.builder( | |
itemBuilder: (BuildContext context, int index,) {final item = items[index]; | |
return ListTile( | |
leading: const CircleAvatar( | |
foregroundImage: AssetImage('assets/images/flutter_logo.png'), | |
), | |
title: Text(CustomAppLocalizations.of(context).listTileTitle(item.id)), | |
onTap: () {Navigator.restorablePushNamed(context, SampleItemDetailsView.routeName,);},);}, | |
itemCount: items.length, | |
restorationId: 'sampleItemListView', | |
), | |
); | |
} | |
} |
Notice that the placeholder call is different in that we have to fill in the placeholder with the right type:
body: ListView.builder( | |
itemBuilder: (BuildContext context, int index,) {final item = items[index]; | |
return ListTile( | |
leading: const CircleAvatar( | |
foregroundImage: AssetImage('assets/images/flutter_logo.png'), | |
), | |
title: Text(CustomAppLocalizations.of(context).listTileTitle(item.id)), | |
onTap: () {Navigator.restorablePushNamed(context, SampleItemDetailsView.routeName,);},);}, | |
itemCount: items.length, | |
restorationId: 'sampleItemListView', | |
), |
And the Sample Items Details View:
// Copyright 2023 Fredrick Allan Grott. All rights reserved. | |
// Use of this source code is governed by a BSD-style | |
// license that can be found in the LICENSE file. | |
import 'package:flutter/material.dart'; | |
import 'package:localization_demo/src/localization/l10n.dart'; | |
/// Displays detailed information about a SampleItem. | |
class SampleItemDetailsView extends StatelessWidget { | |
const SampleItemDetailsView({super.key}); | |
static const routeName = '/sample_item'; | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar( | |
title: Text(CustomAppLocalizations.of(context).sampleItemDetailsViewTitle), | |
), | |
body: Center( | |
child: Text(CustomAppLocalizations.of(context).sampleItemDetailsViewDetail), | |
), | |
); | |
} | |
} |
Thoughts
That is one huge class in large apps we no longer have to write by hand!