Flutter Onboarding Localization: Multilingual Walkthrough and Tutorial Screens
Create onboarding experiences that welcome users in their language. This guide covers localizing welcome screens, feature tutorials, permission explanations, and app introductions in Flutter.
Onboarding Localization Needs
Onboarding flows require localization for:
- Welcome messages - First impressions matter
- Feature explanations - What the app does
- Permission requests - Why you need access
- Action buttons - Next, Skip, Get Started
- Images/illustrations - Culture-appropriate visuals
Localized Onboarding Page Model
Data Structure
class OnboardingPage {
final String titleKey;
final String descriptionKey;
final String imageAsset;
final Map<String, String>? localizedImages;
final Color? backgroundColor;
final bool showSkip;
const OnboardingPage({
required this.titleKey,
required this.descriptionKey,
required this.imageAsset,
this.localizedImages,
this.backgroundColor,
this.showSkip = true,
});
String getTitle(AppLocalizations l10n) {
// Use reflection or a map to get localized string
return _getLocalizedString(l10n, titleKey);
}
String getDescription(AppLocalizations l10n) {
return _getLocalizedString(l10n, descriptionKey);
}
String getImageForLocale(String locale) {
if (localizedImages != null && localizedImages!.containsKey(locale)) {
return localizedImages![locale]!;
}
// Check language only
final lang = locale.split('_').first;
if (localizedImages != null && localizedImages!.containsKey(lang)) {
return localizedImages![lang]!;
}
return imageAsset;
}
String _getLocalizedString(AppLocalizations l10n, String key) {
// Map keys to localized strings
final strings = {
'onboarding_welcome_title': l10n.onboardingWelcomeTitle,
'onboarding_welcome_description': l10n.onboardingWelcomeDescription,
'onboarding_features_title': l10n.onboardingFeaturesTitle,
'onboarding_features_description': l10n.onboardingFeaturesDescription,
'onboarding_sync_title': l10n.onboardingSyncTitle,
'onboarding_sync_description': l10n.onboardingSyncDescription,
'onboarding_notifications_title': l10n.onboardingNotificationsTitle,
'onboarding_notifications_description': l10n.onboardingNotificationsDescription,
'onboarding_ready_title': l10n.onboardingReadyTitle,
'onboarding_ready_description': l10n.onboardingReadyDescription,
};
return strings[key] ?? key;
}
}
// Define onboarding pages
const onboardingPages = [
OnboardingPage(
titleKey: 'onboarding_welcome_title',
descriptionKey: 'onboarding_welcome_description',
imageAsset: 'assets/onboarding/welcome.png',
localizedImages: {
'ar': 'assets/onboarding/welcome_ar.png', // RTL version
'ja': 'assets/onboarding/welcome_ja.png', // Japanese text in image
'zh': 'assets/onboarding/welcome_zh.png', // Chinese text in image
},
backgroundColor: Color(0xFF6C63FF),
),
OnboardingPage(
titleKey: 'onboarding_features_title',
descriptionKey: 'onboarding_features_description',
imageAsset: 'assets/onboarding/features.png',
backgroundColor: Color(0xFF00BFA5),
),
OnboardingPage(
titleKey: 'onboarding_sync_title',
descriptionKey: 'onboarding_sync_description',
imageAsset: 'assets/onboarding/sync.png',
backgroundColor: Color(0xFFFF6B6B),
),
OnboardingPage(
titleKey: 'onboarding_notifications_title',
descriptionKey: 'onboarding_notifications_description',
imageAsset: 'assets/onboarding/notifications.png',
backgroundColor: Color(0xFFFFB347),
showSkip: false, // Don't skip permission explanation
),
OnboardingPage(
titleKey: 'onboarding_ready_title',
descriptionKey: 'onboarding_ready_description',
imageAsset: 'assets/onboarding/ready.png',
backgroundColor: Color(0xFF4ECDC4),
showSkip: false,
),
];
Localized Onboarding Screen
Complete Onboarding Widget
class LocalizedOnboarding extends StatefulWidget {
final VoidCallback onComplete;
const LocalizedOnboarding({required this.onComplete});
@override
State<LocalizedOnboarding> createState() => _LocalizedOnboardingState();
}
class _LocalizedOnboardingState extends State<LocalizedOnboarding> {
final _pageController = PageController();
int _currentPage = 0;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final locale = Localizations.localeOf(context).toString();
final isRtl = Directionality.of(context) == TextDirection.rtl;
return Scaffold(
body: Stack(
children: [
// Page view
PageView.builder(
controller: _pageController,
onPageChanged: (index) => setState(() => _currentPage = index),
itemCount: onboardingPages.length,
reverse: isRtl, // Reverse for RTL
itemBuilder: (context, index) {
return _buildPage(onboardingPages[index], l10n, locale);
},
),
// Skip button
if (onboardingPages[_currentPage].showSkip)
Positioned(
top: MediaQuery.of(context).padding.top + 16,
right: isRtl ? null : 16,
left: isRtl ? 16 : null,
child: TextButton(
onPressed: _skipOnboarding,
child: Text(
l10n.skip,
style: TextStyle(color: Colors.white70),
),
),
),
// Bottom controls
Positioned(
bottom: 0,
left: 0,
right: 0,
child: SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: _buildBottomControls(l10n, isRtl),
),
),
),
],
),
);
}
Widget _buildPage(OnboardingPage page, AppLocalizations l10n, String locale) {
return Container(
color: page.backgroundColor ?? Theme.of(context).primaryColor,
child: SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
children: [
Spacer(),
// Image
Semantics(
label: l10n.illustrationFor(page.getTitle(l10n)),
child: Image.asset(
page.getImageForLocale(locale),
height: 280,
fit: BoxFit.contain,
),
),
SizedBox(height: 48),
// Title
Text(
page.getTitle(l10n),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
SizedBox(height: 16),
// Description
Text(
page.getDescription(l10n),
style: TextStyle(
fontSize: 16,
color: Colors.white70,
height: 1.5,
),
textAlign: TextAlign.center,
),
Spacer(flex: 2),
],
),
),
),
);
}
Widget _buildBottomControls(AppLocalizations l10n, bool isRtl) {
final isLastPage = _currentPage == onboardingPages.length - 1;
return Row(
children: [
// Page indicators
Expanded(
child: Row(
mainAxisAlignment: isRtl
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: List.generate(onboardingPages.length, (index) {
return AnimatedContainer(
duration: Duration(milliseconds: 300),
margin: EdgeInsets.symmetric(horizontal: 4),
width: _currentPage == index ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: _currentPage == index
? Colors.white
: Colors.white38,
borderRadius: BorderRadius.circular(4),
),
);
}),
),
),
// Next/Get Started button
ElevatedButton(
onPressed: isLastPage ? widget.onComplete : _nextPage,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: onboardingPages[_currentPage].backgroundColor,
padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isLastPage ? l10n.getStarted : l10n.next,
style: TextStyle(fontWeight: FontWeight.bold),
),
if (!isLastPage) ...[
SizedBox(width: 8),
Icon(
isRtl ? Icons.arrow_back : Icons.arrow_forward,
size: 18,
),
],
],
),
),
],
);
}
void _nextPage() {
_pageController.nextPage(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
void _skipOnboarding() {
// Track that user skipped
Analytics.track('onboarding_skipped', {
'page': _currentPage,
});
widget.onComplete();
}
}
Permission Request Screens
Localized Permission Explanation
class LocalizedPermissionRequest extends StatelessWidget {
final PermissionType permission;
final VoidCallback onAllow;
final VoidCallback onDeny;
const LocalizedPermissionRequest({
required this.permission,
required this.onAllow,
required this.onDeny,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
padding: EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: _getPermissionColor().withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
_getPermissionIcon(),
size: 60,
color: _getPermissionColor(),
),
),
SizedBox(height: 32),
// Title
Text(
_getTitle(l10n),
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
SizedBox(height: 16),
// Explanation
Text(
_getExplanation(l10n),
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
height: 1.5,
),
textAlign: TextAlign.center,
),
SizedBox(height: 16),
// Benefits list
..._getBenefits(l10n).map((benefit) => _buildBenefitRow(benefit)),
SizedBox(height: 32),
// Buttons
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: onAllow,
child: Text(_getAllowButtonText(l10n)),
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
),
),
),
SizedBox(height: 12),
TextButton(
onPressed: onDeny,
child: Text(l10n.notNow),
),
],
),
);
}
String _getTitle(AppLocalizations l10n) {
switch (permission) {
case PermissionType.notifications:
return l10n.permissionNotificationsTitle;
case PermissionType.location:
return l10n.permissionLocationTitle;
case PermissionType.camera:
return l10n.permissionCameraTitle;
case PermissionType.photos:
return l10n.permissionPhotosTitle;
case PermissionType.microphone:
return l10n.permissionMicrophoneTitle;
case PermissionType.contacts:
return l10n.permissionContactsTitle;
}
}
String _getExplanation(AppLocalizations l10n) {
switch (permission) {
case PermissionType.notifications:
return l10n.permissionNotificationsExplanation;
case PermissionType.location:
return l10n.permissionLocationExplanation;
case PermissionType.camera:
return l10n.permissionCameraExplanation;
case PermissionType.photos:
return l10n.permissionPhotosExplanation;
case PermissionType.microphone:
return l10n.permissionMicrophoneExplanation;
case PermissionType.contacts:
return l10n.permissionContactsExplanation;
}
}
List<String> _getBenefits(AppLocalizations l10n) {
switch (permission) {
case PermissionType.notifications:
return [
l10n.benefitNotifications1,
l10n.benefitNotifications2,
l10n.benefitNotifications3,
];
case PermissionType.location:
return [
l10n.benefitLocation1,
l10n.benefitLocation2,
l10n.benefitLocation3,
];
default:
return [];
}
}
String _getAllowButtonText(AppLocalizations l10n) {
switch (permission) {
case PermissionType.notifications:
return l10n.enableNotifications;
case PermissionType.location:
return l10n.allowLocation;
case PermissionType.camera:
return l10n.allowCamera;
case PermissionType.photos:
return l10n.allowPhotos;
case PermissionType.microphone:
return l10n.allowMicrophone;
case PermissionType.contacts:
return l10n.allowContacts;
}
}
Widget _buildBenefitRow(String benefit) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(Icons.check_circle, color: Colors.green, size: 20),
SizedBox(width: 12),
Expanded(child: Text(benefit)),
],
),
);
}
IconData _getPermissionIcon() {
switch (permission) {
case PermissionType.notifications:
return Icons.notifications_active;
case PermissionType.location:
return Icons.location_on;
case PermissionType.camera:
return Icons.camera_alt;
case PermissionType.photos:
return Icons.photo_library;
case PermissionType.microphone:
return Icons.mic;
case PermissionType.contacts:
return Icons.contacts;
}
}
Color _getPermissionColor() {
switch (permission) {
case PermissionType.notifications:
return Colors.orange;
case PermissionType.location:
return Colors.blue;
case PermissionType.camera:
return Colors.purple;
case PermissionType.photos:
return Colors.green;
case PermissionType.microphone:
return Colors.red;
case PermissionType.contacts:
return Colors.teal;
}
}
}
enum PermissionType {
notifications,
location,
camera,
photos,
microphone,
contacts,
}
Feature Highlights
Animated Feature Showcase
class LocalizedFeatureHighlight extends StatelessWidget {
final List<Feature> features;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ListView.builder(
padding: EdgeInsets.all(24),
itemCount: features.length,
itemBuilder: (context, index) {
return _buildFeatureCard(features[index], l10n, index);
},
);
}
Widget _buildFeatureCard(Feature feature, AppLocalizations l10n, int index) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: Duration(milliseconds: 500 + (index * 100)),
builder: (context, value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(0, 20 * (1 - value)),
child: child,
),
);
},
child: Card(
margin: EdgeInsets.only(bottom: 16),
child: Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: feature.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
feature.icon,
color: feature.color,
size: 30,
),
),
SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
feature.getTitle(l10n),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
SizedBox(height: 4),
Text(
feature.getDescription(l10n),
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
),
],
),
),
],
),
),
),
);
}
}
class Feature {
final String titleKey;
final String descriptionKey;
final IconData icon;
final Color color;
const Feature({
required this.titleKey,
required this.descriptionKey,
required this.icon,
required this.color,
});
String getTitle(AppLocalizations l10n) {
final titles = {
'feature_sync': l10n.featureSync,
'feature_offline': l10n.featureOffline,
'feature_share': l10n.featureShare,
'feature_secure': l10n.featureSecure,
};
return titles[titleKey] ?? titleKey;
}
String getDescription(AppLocalizations l10n) {
final descriptions = {
'feature_sync_desc': l10n.featureSyncDesc,
'feature_offline_desc': l10n.featureOfflineDesc,
'feature_share_desc': l10n.featureShareDesc,
'feature_secure_desc': l10n.featureSecureDesc,
};
return descriptions[descriptionKey] ?? descriptionKey;
}
}
ARB File Structure
{
"@@locale": "en",
"skip": "Skip",
"next": "Next",
"back": "Back",
"getStarted": "Get Started",
"done": "Done",
"notNow": "Not Now",
"onboardingWelcomeTitle": "Welcome to AppName",
"onboardingWelcomeDescription": "Your personal assistant for managing tasks and staying productive.",
"onboardingFeaturesTitle": "Powerful Features",
"onboardingFeaturesDescription": "Everything you need to organize your work and life in one place.",
"onboardingSyncTitle": "Sync Everywhere",
"onboardingSyncDescription": "Access your data on any device. Changes sync instantly across all your devices.",
"onboardingNotificationsTitle": "Stay Updated",
"onboardingNotificationsDescription": "Get timely reminders and never miss important tasks or deadlines.",
"onboardingReadyTitle": "You're All Set!",
"onboardingReadyDescription": "Start organizing your life today. We're here to help you succeed.",
"illustrationFor": "Illustration for {title}",
"@illustrationFor": {
"placeholders": {"title": {"type": "String"}}
},
"permissionNotificationsTitle": "Enable Notifications",
"permissionNotificationsExplanation": "Get reminded about important tasks and updates. You can customize notification preferences anytime.",
"benefitNotifications1": "Never miss a deadline",
"benefitNotifications2": "Get updates on shared tasks",
"benefitNotifications3": "Receive daily summaries",
"enableNotifications": "Enable Notifications",
"permissionLocationTitle": "Enable Location",
"permissionLocationExplanation": "Help us show relevant content and enable location-based reminders.",
"benefitLocation1": "Location-based reminders",
"benefitLocation2": "Find nearby places",
"benefitLocation3": "Weather-aware suggestions",
"allowLocation": "Allow Location Access",
"permissionCameraTitle": "Camera Access",
"permissionCameraExplanation": "Take photos of documents, receipts, or notes to add to your tasks.",
"allowCamera": "Allow Camera",
"permissionPhotosTitle": "Photo Library Access",
"permissionPhotosExplanation": "Attach photos from your library to tasks and notes.",
"allowPhotos": "Allow Photos",
"permissionMicrophoneTitle": "Microphone Access",
"permissionMicrophoneExplanation": "Create voice notes and hands-free task entry.",
"allowMicrophone": "Allow Microphone",
"permissionContactsTitle": "Contacts Access",
"permissionContactsExplanation": "Easily share tasks and collaborate with your contacts.",
"allowContacts": "Allow Contacts",
"featureSync": "Cloud Sync",
"featureSyncDesc": "Your data syncs automatically across all devices",
"featureOffline": "Offline Mode",
"featureOfflineDesc": "Works without internet, syncs when connected",
"featureShare": "Easy Sharing",
"featureShareDesc": "Share tasks and lists with friends and family",
"featureSecure": "Bank-Level Security",
"featureSecureDesc": "Your data is encrypted and protected"
}
Conclusion
Onboarding localization requires:
- Culturally appropriate visuals per locale
- Clear permission explanations in native language
- RTL support for bidirectional layouts
- Benefit-focused copy that resonates locally
- Skip and progress options properly labeled
With proper localization, your onboarding will convert users worldwide into engaged customers.