Flutter IndexedStack Localization: Tab Navigation, View Switching, and State Preservation
IndexedStack displays a single child from a list of children while preserving the state of all children. This widget is essential for tab-based navigation and view switching. Proper localization ensures navigation labels, page titles, and content all work seamlessly across languages. This guide covers comprehensive strategies for localizing IndexedStack widgets in Flutter.
Understanding IndexedStack Localization
IndexedStack widgets require localization for:
- Tab labels: Navigation item text in bottom bars or tabs
- Page titles: AppBar titles that change with each page
- Page content: All content within each stacked view
- Accessibility labels: Screen reader descriptions for navigation
- Empty states: Messages when pages have no content
- Navigation hints: Instructions for switching between views
Basic IndexedStack with Bottom Navigation
Start with a simple tab navigation pattern:
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LocalizedIndexedStack extends StatefulWidget {
const LocalizedIndexedStack({super.key});
@override
State<LocalizedIndexedStack> createState() => _LocalizedIndexedStackState();
}
class _LocalizedIndexedStackState extends State<LocalizedIndexedStack> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(_getPageTitle(l10n)),
),
body: IndexedStack(
index: _currentIndex,
children: [
_HomeView(),
_SearchView(),
_FavoritesView(),
_ProfileView(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
setState(() {
_currentIndex = index;
});
},
destinations: [
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: l10n.homeTab,
tooltip: l10n.homeTabTooltip,
),
NavigationDestination(
icon: const Icon(Icons.search_outlined),
selectedIcon: const Icon(Icons.search),
label: l10n.searchTab,
tooltip: l10n.searchTabTooltip,
),
NavigationDestination(
icon: const Icon(Icons.favorite_outline),
selectedIcon: const Icon(Icons.favorite),
label: l10n.favoritesTab,
tooltip: l10n.favoritesTabTooltip,
),
NavigationDestination(
icon: const Icon(Icons.person_outline),
selectedIcon: const Icon(Icons.person),
label: l10n.profileTab,
tooltip: l10n.profileTabTooltip,
),
],
),
);
}
String _getPageTitle(AppLocalizations l10n) {
return switch (_currentIndex) {
0 => l10n.homeTitle,
1 => l10n.searchTitle,
2 => l10n.favoritesTitle,
3 => l10n.profileTitle,
_ => l10n.appName,
};
}
}
class _HomeView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.home, size: 64),
const SizedBox(height: 16),
Text(
l10n.welcomeMessage,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(l10n.homeDescription),
],
),
);
}
}
class _SearchView extends StatefulWidget {
@override
State<_SearchView> createState() => _SearchViewState();
}
class _SearchViewState extends State<_SearchView> {
final _searchController = TextEditingController();
List<String> _results = [];
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: l10n.searchHint,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
tooltip: l10n.clearSearchTooltip,
onPressed: () {
setState(() {
_searchController.clear();
_results.clear();
});
},
)
: null,
border: const OutlineInputBorder(),
),
onChanged: (value) => _performSearch(value),
),
),
Expanded(
child: _results.isEmpty
? Center(
child: Text(
_searchController.text.isEmpty
? l10n.searchPrompt
: l10n.noSearchResults,
),
)
: ListView.builder(
itemCount: _results.length,
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.article),
title: Text(_results[index]),
subtitle: Text(l10n.resultNumber(index + 1)),
);
},
),
),
],
);
}
void _performSearch(String query) {
final l10n = AppLocalizations.of(context)!;
if (query.isEmpty) {
setState(() => _results.clear());
return;
}
// Simulate search results
setState(() {
_results = List.generate(
5,
(index) => l10n.searchResult(query, index + 1),
);
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
}
class _FavoritesView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.favorite, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(l10n.noFavoritesYet),
const SizedBox(height: 8),
Text(l10n.addFavoritesHint),
],
),
);
}
}
class _ProfileView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListView(
padding: const EdgeInsets.all(16),
children: [
const CircleAvatar(
radius: 50,
child: Icon(Icons.person, size: 50),
),
const SizedBox(height: 16),
Text(
l10n.guestUser,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 24),
ListTile(
leading: const Icon(Icons.settings),
title: Text(l10n.settingsOption),
trailing: const Icon(Icons.chevron_right),
),
ListTile(
leading: const Icon(Icons.language),
title: Text(l10n.languageOption),
trailing: const Icon(Icons.chevron_right),
),
ListTile(
leading: const Icon(Icons.help),
title: Text(l10n.helpOption),
trailing: const Icon(Icons.chevron_right),
),
ListTile(
leading: const Icon(Icons.info),
title: Text(l10n.aboutOption),
trailing: const Icon(Icons.chevron_right),
),
],
);
}
}
IndexedStack with State Preservation
Preserve form state across tab switches:
class StatefulIndexedStack extends StatefulWidget {
const StatefulIndexedStack({super.key});
@override
State<StatefulIndexedStack> createState() => _StatefulIndexedStackState();
}
class _StatefulIndexedStackState extends State<StatefulIndexedStack> {
int _currentIndex = 0;
// Controllers to preserve state
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _messageController = TextEditingController();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(_getStepTitle(l10n)),
actions: [
if (_currentIndex > 0)
TextButton(
onPressed: () => setState(() => _currentIndex--),
child: Text(l10n.previousStep),
),
if (_currentIndex < 2)
TextButton(
onPressed: () => setState(() => _currentIndex++),
child: Text(l10n.nextStep),
),
],
),
body: Column(
children: [
// Step indicator
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
_buildStepIndicator(0, l10n.personalInfoStep),
_buildStepConnector(),
_buildStepIndicator(1, l10n.contactInfoStep),
_buildStepConnector(),
_buildStepIndicator(2, l10n.reviewStep),
],
),
),
Expanded(
child: IndexedStack(
index: _currentIndex,
children: [
_PersonalInfoForm(
nameController: _nameController,
),
_ContactInfoForm(
emailController: _emailController,
messageController: _messageController,
),
_ReviewForm(
name: _nameController.text,
email: _emailController.text,
message: _messageController.text,
),
],
),
),
],
),
);
}
Widget _buildStepIndicator(int step, String label) {
final isActive = step == _currentIndex;
final isCompleted = step < _currentIndex;
return Expanded(
child: Column(
children: [
CircleAvatar(
radius: 16,
backgroundColor: isCompleted
? Colors.green
: isActive
? Theme.of(context).primaryColor
: Colors.grey[300],
child: isCompleted
? const Icon(Icons.check, size: 16, color: Colors.white)
: Text(
'${step + 1}',
style: TextStyle(
color: isActive ? Colors.white : Colors.grey[600],
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
color: isActive ? Theme.of(context).primaryColor : Colors.grey,
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildStepConnector() {
return Container(
width: 24,
height: 2,
color: Colors.grey[300],
);
}
String _getStepTitle(AppLocalizations l10n) {
return switch (_currentIndex) {
0 => l10n.personalInfoTitle,
1 => l10n.contactInfoTitle,
2 => l10n.reviewTitle,
_ => l10n.formTitle,
};
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_messageController.dispose();
super.dispose();
}
}
class _PersonalInfoForm extends StatelessWidget {
final TextEditingController nameController;
const _PersonalInfoForm({required this.nameController});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.personalInfoDescription,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 24),
TextField(
controller: nameController,
decoration: InputDecoration(
labelText: l10n.fullNameLabel,
hintText: l10n.fullNameHint,
prefixIcon: const Icon(Icons.person),
border: const OutlineInputBorder(),
),
),
],
),
);
}
}
class _ContactInfoForm extends StatelessWidget {
final TextEditingController emailController;
final TextEditingController messageController;
const _ContactInfoForm({
required this.emailController,
required this.messageController,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.contactInfoDescription,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 24),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: l10n.emailLabel,
hintText: l10n.emailHint,
prefixIcon: const Icon(Icons.email),
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: messageController,
maxLines: 4,
decoration: InputDecoration(
labelText: l10n.messageLabel,
hintText: l10n.messageHint,
prefixIcon: const Icon(Icons.message),
alignLabelWithHint: true,
border: const OutlineInputBorder(),
),
),
],
),
);
}
}
class _ReviewForm extends StatelessWidget {
final String name;
final String email;
final String message;
const _ReviewForm({
required this.name,
required this.email,
required this.message,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.reviewDescription,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildReviewItem(
context,
l10n.fullNameLabel,
name.isEmpty ? l10n.notProvided : name,
),
const Divider(),
_buildReviewItem(
context,
l10n.emailLabel,
email.isEmpty ? l10n.notProvided : email,
),
const Divider(),
_buildReviewItem(
context,
l10n.messageLabel,
message.isEmpty ? l10n.notProvided : message,
),
],
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.formSubmitted)),
);
},
child: Text(l10n.submitButton),
),
),
],
),
);
}
Widget _buildReviewItem(BuildContext context, String label, String value) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
child: Text(value),
),
],
);
}
}
IndexedStack with Badge Notifications
Show localized badge counts on navigation items:
class BadgedIndexedStack extends StatefulWidget {
const BadgedIndexedStack({super.key});
@override
State<BadgedIndexedStack> createState() => _BadgedIndexedStackState();
}
class _BadgedIndexedStackState extends State<BadgedIndexedStack> {
int _currentIndex = 0;
int _notificationCount = 3;
int _cartItemCount = 2;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(_getTitle(l10n)),
actions: _buildActions(l10n),
),
body: IndexedStack(
index: _currentIndex,
children: [
_buildHomeContent(l10n),
_buildNotificationsContent(l10n),
_buildCartContent(l10n),
_buildSettingsContent(l10n),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
setState(() {
_currentIndex = index;
// Clear badge when viewing notifications
if (index == 1) _notificationCount = 0;
});
},
destinations: [
NavigationDestination(
icon: const Icon(Icons.home_outlined),
selectedIcon: const Icon(Icons.home),
label: l10n.homeTab,
),
NavigationDestination(
icon: Badge(
isLabelVisible: _notificationCount > 0,
label: Text(
_notificationCount > 99 ? '99+' : '$_notificationCount',
),
child: const Icon(Icons.notifications_outlined),
),
selectedIcon: Badge(
isLabelVisible: _notificationCount > 0,
label: Text(
_notificationCount > 99 ? '99+' : '$_notificationCount',
),
child: const Icon(Icons.notifications),
),
label: l10n.notificationsTab,
),
NavigationDestination(
icon: Badge(
isLabelVisible: _cartItemCount > 0,
label: Text('$_cartItemCount'),
child: const Icon(Icons.shopping_cart_outlined),
),
selectedIcon: Badge(
isLabelVisible: _cartItemCount > 0,
label: Text('$_cartItemCount'),
child: const Icon(Icons.shopping_cart),
),
label: l10n.cartTab,
),
NavigationDestination(
icon: const Icon(Icons.settings_outlined),
selectedIcon: const Icon(Icons.settings),
label: l10n.settingsTab,
),
],
),
);
}
String _getTitle(AppLocalizations l10n) {
return switch (_currentIndex) {
0 => l10n.homeTitle,
1 => l10n.notificationsTitle,
2 => l10n.cartTitle,
3 => l10n.settingsTitle,
_ => l10n.appName,
};
}
List<Widget>? _buildActions(AppLocalizations l10n) {
if (_currentIndex == 1 && _notificationCount > 0) {
return [
TextButton(
onPressed: () {
setState(() => _notificationCount = 0);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.allNotificationsCleared)),
);
},
child: Text(l10n.clearAllAction),
),
];
}
return null;
}
Widget _buildHomeContent(AppLocalizations l10n) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.welcomeBack,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(l10n.homeWelcomeDescription),
const SizedBox(height: 16),
if (_notificationCount > 0)
_buildSummaryTile(
Icons.notifications,
l10n.unreadNotifications(_notificationCount),
Colors.orange,
),
if (_cartItemCount > 0)
_buildSummaryTile(
Icons.shopping_cart,
l10n.itemsInCart(_cartItemCount),
Colors.blue,
),
],
),
),
),
],
);
}
Widget _buildSummaryTile(IconData icon, String text, Color color) {
return ListTile(
leading: Icon(icon, color: color),
title: Text(text),
dense: true,
);
}
Widget _buildNotificationsContent(AppLocalizations l10n) {
if (_notificationCount == 0) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.notifications_none, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(l10n.noNotifications),
],
),
);
}
return ListView.builder(
itemCount: _notificationCount,
itemBuilder: (context, index) {
return ListTile(
leading: const CircleAvatar(child: Icon(Icons.message)),
title: Text(l10n.notificationTitle),
subtitle: Text(l10n.notificationSubtitle(index + 1)),
trailing: Text(l10n.hoursAgo(index + 1)),
);
},
);
}
Widget _buildCartContent(AppLocalizations l10n) {
if (_cartItemCount == 0) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.shopping_cart_outlined, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(l10n.emptyCart),
const SizedBox(height: 8),
Text(l10n.startShoppingHint),
],
),
);
}
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: _cartItemCount,
itemBuilder: (context, index) {
return ListTile(
leading: Container(
width: 50,
height: 50,
color: Colors.grey[300],
child: const Icon(Icons.image),
),
title: Text(l10n.cartItemName(index + 1)),
subtitle: Text(l10n.cartItemPrice(19.99 + index * 10)),
trailing: IconButton(
icon: const Icon(Icons.delete),
tooltip: l10n.removeFromCartTooltip,
onPressed: () {
setState(() => _cartItemCount--);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.itemRemovedFromCart)),
);
},
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
l10n.totalLabel,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
l10n.cartItemPrice(
List.generate(
_cartItemCount,
(i) => 19.99 + i * 10,
).fold(0.0, (a, b) => a + b),
),
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {},
child: Text(l10n.checkoutButton),
),
),
],
),
),
],
);
}
Widget _buildSettingsContent(AppLocalizations l10n) {
return ListView(
children: [
ListTile(
leading: const Icon(Icons.language),
title: Text(l10n.languageSetting),
subtitle: Text(l10n.currentLanguage),
trailing: const Icon(Icons.chevron_right),
),
ListTile(
leading: const Icon(Icons.dark_mode),
title: Text(l10n.themeSetting),
subtitle: Text(l10n.systemTheme),
trailing: const Icon(Icons.chevron_right),
),
ListTile(
leading: const Icon(Icons.notifications),
title: Text(l10n.notificationsSetting),
subtitle: Text(l10n.notificationsEnabled),
trailing: Switch(value: true, onChanged: (_) {}),
),
const Divider(),
ListTile(
leading: const Icon(Icons.help),
title: Text(l10n.helpSetting),
trailing: const Icon(Icons.chevron_right),
),
ListTile(
leading: const Icon(Icons.info),
title: Text(l10n.aboutSetting),
trailing: const Icon(Icons.chevron_right),
),
],
);
}
}
Accessible IndexedStack Navigation
Provide full accessibility support:
class AccessibleIndexedNavigation extends StatefulWidget {
const AccessibleIndexedNavigation({super.key});
@override
State<AccessibleIndexedNavigation> createState() => _AccessibleIndexedNavigationState();
}
class _AccessibleIndexedNavigationState extends State<AccessibleIndexedNavigation> {
int _currentIndex = 0;
final List<FocusNode> _tabFocusNodes = List.generate(4, (_) => FocusNode());
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
body: Column(
children: [
// Accessible header with navigation context
Semantics(
header: true,
label: l10n.currentSection(_getTabName(l10n, _currentIndex)),
child: AppBar(
title: Text(_getTabName(l10n, _currentIndex)),
),
),
// Main content
Expanded(
child: Semantics(
label: l10n.mainContentLabel(_getTabName(l10n, _currentIndex)),
child: IndexedStack(
index: _currentIndex,
children: [
_AccessibleHomeView(),
_AccessibleExploreView(),
_AccessibleActivityView(),
_AccessibleAccountView(),
],
),
),
),
],
),
bottomNavigationBar: Semantics(
label: l10n.navigationBarLabel,
hint: l10n.navigationBarHint,
child: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (index) {
setState(() {
_currentIndex = index;
});
// Announce tab change
SemanticsService.announce(
l10n.selectedTab(_getTabName(l10n, index)),
Directionality.of(context),
);
},
destinations: [
_buildAccessibleDestination(
context,
l10n,
icon: Icons.home_outlined,
selectedIcon: Icons.home,
label: l10n.homeTab,
index: 0,
),
_buildAccessibleDestination(
context,
l10n,
icon: Icons.explore_outlined,
selectedIcon: Icons.explore,
label: l10n.exploreTab,
index: 1,
),
_buildAccessibleDestination(
context,
l10n,
icon: Icons.notifications_outlined,
selectedIcon: Icons.notifications,
label: l10n.activityTab,
index: 2,
),
_buildAccessibleDestination(
context,
l10n,
icon: Icons.account_circle_outlined,
selectedIcon: Icons.account_circle,
label: l10n.accountTab,
index: 3,
),
],
),
),
);
}
NavigationDestination _buildAccessibleDestination(
BuildContext context,
AppLocalizations l10n, {
required IconData icon,
required IconData selectedIcon,
required String label,
required int index,
}) {
final isSelected = _currentIndex == index;
return NavigationDestination(
icon: Semantics(
label: l10n.tabAccessibilityLabel(label, isSelected),
selected: isSelected,
child: Icon(icon),
),
selectedIcon: Semantics(
label: l10n.tabAccessibilityLabel(label, isSelected),
selected: isSelected,
child: Icon(selectedIcon),
),
label: label,
tooltip: l10n.tabTooltip(label, index + 1, 4),
);
}
String _getTabName(AppLocalizations l10n, int index) {
return switch (index) {
0 => l10n.homeTab,
1 => l10n.exploreTab,
2 => l10n.activityTab,
3 => l10n.accountTab,
_ => '',
};
}
@override
void dispose() {
for (final node in _tabFocusNodes) {
node.dispose();
}
super.dispose();
}
}
class _AccessibleHomeView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Semantics(
explicitChildNodes: true,
child: ListView(
children: [
Semantics(
header: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
l10n.welcomeSection,
style: Theme.of(context).textTheme.headlineSmall,
),
),
),
Semantics(
label: l10n.featuredContentLabel,
child: Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(l10n.featuredContent),
),
),
),
],
),
);
}
}
class _AccessibleExploreView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(child: Text(l10n.exploreContent));
}
}
class _AccessibleActivityView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(child: Text(l10n.activityContent));
}
}
class _AccessibleAccountView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Center(child: Text(l10n.accountContent));
}
}
ARB File Structure
Define all IndexedStack-related translations:
{
"@@locale": "en",
"appName": "My App",
"@appName": {
"description": "Application name"
},
"homeTab": "Home",
"@homeTab": {
"description": "Home tab label"
},
"homeTabTooltip": "Go to home",
"@homeTabTooltip": {
"description": "Home tab tooltip"
},
"searchTab": "Search",
"@searchTab": {
"description": "Search tab label"
},
"searchTabTooltip": "Search content",
"@searchTabTooltip": {
"description": "Search tab tooltip"
},
"favoritesTab": "Favorites",
"@favoritesTab": {
"description": "Favorites tab label"
},
"favoritesTabTooltip": "View favorites",
"@favoritesTabTooltip": {
"description": "Favorites tab tooltip"
},
"profileTab": "Profile",
"@profileTab": {
"description": "Profile tab label"
},
"profileTabTooltip": "View profile",
"@profileTabTooltip": {
"description": "Profile tab tooltip"
},
"homeTitle": "Home",
"@homeTitle": {
"description": "Home page title"
},
"searchTitle": "Search",
"@searchTitle": {
"description": "Search page title"
},
"favoritesTitle": "Favorites",
"@favoritesTitle": {
"description": "Favorites page title"
},
"profileTitle": "Profile",
"@profileTitle": {
"description": "Profile page title"
},
"welcomeMessage": "Welcome!",
"@welcomeMessage": {
"description": "Welcome message"
},
"homeDescription": "This is your home screen",
"@homeDescription": {
"description": "Home description"
},
"searchHint": "Search...",
"@searchHint": {
"description": "Search field hint"
},
"clearSearchTooltip": "Clear search",
"@clearSearchTooltip": {
"description": "Clear search tooltip"
},
"searchPrompt": "Enter a search term",
"@searchPrompt": {
"description": "Search prompt"
},
"noSearchResults": "No results found",
"@noSearchResults": {
"description": "No search results message"
},
"resultNumber": "Result #{number}",
"@resultNumber": {
"description": "Result number label",
"placeholders": {
"number": {
"type": "int"
}
}
},
"searchResult": "Result for \"{query}\" #{number}",
"@searchResult": {
"description": "Search result item",
"placeholders": {
"query": {
"type": "String"
},
"number": {
"type": "int"
}
}
},
"noFavoritesYet": "No favorites yet",
"@noFavoritesYet": {
"description": "No favorites message"
},
"addFavoritesHint": "Items you favorite will appear here",
"@addFavoritesHint": {
"description": "Add favorites hint"
},
"guestUser": "Guest User",
"@guestUser": {
"description": "Guest user label"
},
"settingsOption": "Settings",
"@settingsOption": {
"description": "Settings option"
},
"languageOption": "Language",
"@languageOption": {
"description": "Language option"
},
"helpOption": "Help",
"@helpOption": {
"description": "Help option"
},
"aboutOption": "About",
"@aboutOption": {
"description": "About option"
},
"previousStep": "Previous",
"@previousStep": {
"description": "Previous step button"
},
"nextStep": "Next",
"@nextStep": {
"description": "Next step button"
},
"personalInfoStep": "Personal",
"@personalInfoStep": {
"description": "Personal info step"
},
"contactInfoStep": "Contact",
"@contactInfoStep": {
"description": "Contact info step"
},
"reviewStep": "Review",
"@reviewStep": {
"description": "Review step"
},
"personalInfoTitle": "Personal Information",
"@personalInfoTitle": {
"description": "Personal info title"
},
"contactInfoTitle": "Contact Information",
"@contactInfoTitle": {
"description": "Contact info title"
},
"reviewTitle": "Review",
"@reviewTitle": {
"description": "Review title"
},
"formTitle": "Form",
"@formTitle": {
"description": "Form title"
},
"personalInfoDescription": "Please enter your personal information",
"@personalInfoDescription": {
"description": "Personal info description"
},
"fullNameLabel": "Full Name",
"@fullNameLabel": {
"description": "Full name label"
},
"fullNameHint": "Enter your full name",
"@fullNameHint": {
"description": "Full name hint"
},
"contactInfoDescription": "How can we reach you?",
"@contactInfoDescription": {
"description": "Contact info description"
},
"emailLabel": "Email",
"@emailLabel": {
"description": "Email label"
},
"emailHint": "Enter your email",
"@emailHint": {
"description": "Email hint"
},
"messageLabel": "Message",
"@messageLabel": {
"description": "Message label"
},
"messageHint": "Enter your message",
"@messageHint": {
"description": "Message hint"
},
"reviewDescription": "Please review your information",
"@reviewDescription": {
"description": "Review description"
},
"notProvided": "Not provided",
"@notProvided": {
"description": "Not provided label"
},
"submitButton": "Submit",
"@submitButton": {
"description": "Submit button"
},
"formSubmitted": "Form submitted successfully!",
"@formSubmitted": {
"description": "Form submitted message"
},
"notificationsTab": "Notifications",
"@notificationsTab": {
"description": "Notifications tab"
},
"cartTab": "Cart",
"@cartTab": {
"description": "Cart tab"
},
"settingsTab": "Settings",
"@settingsTab": {
"description": "Settings tab"
},
"notificationsTitle": "Notifications",
"@notificationsTitle": {
"description": "Notifications title"
},
"cartTitle": "Shopping Cart",
"@cartTitle": {
"description": "Cart title"
},
"settingsTitle": "Settings",
"@settingsTitle": {
"description": "Settings title"
},
"clearAllAction": "Clear All",
"@clearAllAction": {
"description": "Clear all action"
},
"allNotificationsCleared": "All notifications cleared",
"@allNotificationsCleared": {
"description": "Notifications cleared message"
},
"welcomeBack": "Welcome back!",
"@welcomeBack": {
"description": "Welcome back greeting"
},
"homeWelcomeDescription": "Here's what's happening",
"@homeWelcomeDescription": {
"description": "Home welcome description"
},
"unreadNotifications": "{count, plural, =1{1 unread notification} other{{count} unread notifications}}",
"@unreadNotifications": {
"description": "Unread notifications count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"itemsInCart": "{count, plural, =1{1 item in cart} other{{count} items in cart}}",
"@itemsInCart": {
"description": "Items in cart count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"noNotifications": "No notifications",
"@noNotifications": {
"description": "No notifications message"
},
"notificationTitle": "New notification",
"@notificationTitle": {
"description": "Notification title"
},
"notificationSubtitle": "Notification #{number}",
"@notificationSubtitle": {
"description": "Notification subtitle",
"placeholders": {
"number": {
"type": "int"
}
}
},
"hoursAgo": "{hours}h ago",
"@hoursAgo": {
"description": "Hours ago label",
"placeholders": {
"hours": {
"type": "int"
}
}
},
"emptyCart": "Your cart is empty",
"@emptyCart": {
"description": "Empty cart message"
},
"startShoppingHint": "Start shopping to add items",
"@startShoppingHint": {
"description": "Start shopping hint"
},
"cartItemName": "Product {number}",
"@cartItemName": {
"description": "Cart item name",
"placeholders": {
"number": {
"type": "int"
}
}
},
"cartItemPrice": "${price}",
"@cartItemPrice": {
"description": "Cart item price",
"placeholders": {
"price": {
"type": "double",
"format": "currency",
"optionalParameters": {
"symbol": "$",
"decimalDigits": 2
}
}
}
},
"removeFromCartTooltip": "Remove from cart",
"@removeFromCartTooltip": {
"description": "Remove from cart tooltip"
},
"itemRemovedFromCart": "Item removed from cart",
"@itemRemovedFromCart": {
"description": "Item removed message"
},
"totalLabel": "Total",
"@totalLabel": {
"description": "Total label"
},
"checkoutButton": "Checkout",
"@checkoutButton": {
"description": "Checkout button"
},
"languageSetting": "Language",
"@languageSetting": {
"description": "Language setting"
},
"currentLanguage": "English",
"@currentLanguage": {
"description": "Current language"
},
"themeSetting": "Theme",
"@themeSetting": {
"description": "Theme setting"
},
"systemTheme": "System default",
"@systemTheme": {
"description": "System theme"
},
"notificationsSetting": "Notifications",
"@notificationsSetting": {
"description": "Notifications setting"
},
"notificationsEnabled": "Enabled",
"@notificationsEnabled": {
"description": "Notifications enabled"
},
"helpSetting": "Help & Support",
"@helpSetting": {
"description": "Help setting"
},
"aboutSetting": "About",
"@aboutSetting": {
"description": "About setting"
},
"currentSection": "Current section: {section}",
"@currentSection": {
"description": "Current section announcement",
"placeholders": {
"section": {
"type": "String"
}
}
},
"mainContentLabel": "{section} content",
"@mainContentLabel": {
"description": "Main content label",
"placeholders": {
"section": {
"type": "String"
}
}
},
"navigationBarLabel": "Main navigation",
"@navigationBarLabel": {
"description": "Navigation bar label"
},
"navigationBarHint": "Use left and right to navigate between tabs",
"@navigationBarHint": {
"description": "Navigation bar hint"
},
"selectedTab": "Selected {tab}",
"@selectedTab": {
"description": "Selected tab announcement",
"placeholders": {
"tab": {
"type": "String"
}
}
},
"exploreTab": "Explore",
"@exploreTab": {
"description": "Explore tab"
},
"activityTab": "Activity",
"@activityTab": {
"description": "Activity tab"
},
"accountTab": "Account",
"@accountTab": {
"description": "Account tab"
},
"tabAccessibilityLabel": "{label}, {selected, select, true{selected} other{not selected}}",
"@tabAccessibilityLabel": {
"description": "Tab accessibility label",
"placeholders": {
"label": {
"type": "String"
},
"selected": {
"type": "String"
}
}
},
"tabTooltip": "{label}, tab {current} of {total}",
"@tabTooltip": {
"description": "Tab tooltip",
"placeholders": {
"label": {
"type": "String"
},
"current": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"welcomeSection": "Welcome",
"@welcomeSection": {
"description": "Welcome section header"
},
"featuredContentLabel": "Featured content section",
"@featuredContentLabel": {
"description": "Featured content accessibility label"
},
"featuredContent": "Check out our featured content",
"@featuredContent": {
"description": "Featured content text"
},
"exploreContent": "Explore new things",
"@exploreContent": {
"description": "Explore content"
},
"activityContent": "Your recent activity",
"@activityContent": {
"description": "Activity content"
},
"accountContent": "Manage your account",
"@accountContent": {
"description": "Account content"
}
}
Testing IndexedStack Localization
Comprehensive tests for IndexedStack:
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('IndexedStack Localization Tests', () {
testWidgets('displays localized tab labels', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('es'),
home: const LocalizedIndexedStack(),
),
);
// Verify Spanish tab labels
expect(find.text('Inicio'), findsOneWidget);
expect(find.text('Buscar'), findsOneWidget);
expect(find.text('Favoritos'), findsOneWidget);
expect(find.text('Perfil'), findsOneWidget);
});
testWidgets('updates title when switching tabs', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('fr'),
home: const LocalizedIndexedStack(),
),
);
// Initially shows home title
expect(find.text('Accueil'), findsWidgets);
// Tap search tab
await tester.tap(find.text('Recherche'));
await tester.pumpAndSettle();
// Title should update
expect(find.text('Recherche'), findsWidgets);
});
testWidgets('preserves state across tab switches', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const StatefulIndexedStack(),
),
);
// Enter text in first form
await tester.enterText(find.byType(TextField).first, 'Test Name');
await tester.pump();
// Switch to next step
await tester.tap(find.text('Next'));
await tester.pumpAndSettle();
// Go back to first step
await tester.tap(find.text('Previous'));
await tester.pumpAndSettle();
// Text should be preserved
expect(find.text('Test Name'), findsOneWidget);
});
testWidgets('announces tab changes for screen readers', (tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const AccessibleIndexedNavigation(),
),
);
// Verify navigation bar has semantics
expect(
find.bySemanticsLabel(RegExp('navigation')),
findsWidgets,
);
handle.dispose();
});
testWidgets('handles RTL layout correctly', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('ar'),
home: const Directionality(
textDirection: TextDirection.rtl,
child: LocalizedIndexedStack(),
),
),
);
// Navigation should be RTL
expect(find.byType(NavigationBar), findsOneWidget);
});
});
}
Best Practices Summary
Preserve state: IndexedStack keeps all children alive, so form data and scroll positions are maintained when switching tabs
Update titles dynamically: AppBar titles should reflect the current page in the user's language
Provide tooltips: Add localized tooltips to navigation items for accessibility
Announce tab changes: Use
SemanticsService.announceto inform screen reader users of navigationHandle badge counts: Localize badge text and use proper pluralization for counts
Test state preservation: Verify that user input is maintained across tab switches
Support keyboard navigation: Ensure users can navigate between tabs using keyboard
Consider deep linking: Plan for navigation to specific tabs from external sources
By following these patterns, your IndexedStack navigation will provide a smooth, accessible, and properly localized experience across all languages.