Flutter Dynamic Asset Localization: Images, Fonts, Audio, and Videos per Locale
Localization goes beyond translating text. Images with text overlays, culturally specific icons, locale-appropriate fonts, and audio in the user's language all contribute to an authentic experience. This guide covers comprehensive strategies for localizing assets in Flutter.
Why Localize Assets?
Assets that need localization include:
- Images with text - Screenshots, infographics, marketing banners
- Cultural imagery - Icons, illustrations that vary by region
- Fonts - Different scripts require different typefaces (Arabic, CJK, Devanagari)
- Audio - Voice prompts, sound effects with spoken content
- Videos - Tutorials, onboarding videos with narration
- Documents - PDFs, legal documents by region
Project Structure for Localized Assets
Organize assets by locale:
assets/
├── images/
│ ├── common/ # Shared across all locales
│ │ ├── logo.png
│ │ └── icons/
│ ├── en/ # English assets
│ │ ├── onboarding_1.png
│ │ ├── onboarding_2.png
│ │ └── banner_promo.png
│ ├── es/ # Spanish assets
│ │ ├── onboarding_1.png
│ │ ├── onboarding_2.png
│ │ └── banner_promo.png
│ ├── ar/ # Arabic assets (RTL)
│ │ ├── onboarding_1.png
│ │ └── banner_promo.png
│ └── ja/ # Japanese assets
│ └── onboarding_1.png
├── fonts/
│ ├── Roboto/ # Latin scripts
│ ├── NotoSansArabic/ # Arabic script
│ ├── NotoSansCJK/ # Chinese, Japanese, Korean
│ └── NotoSansDevanagari/ # Hindi, Sanskrit
├── audio/
│ ├── en/
│ │ └── welcome.mp3
│ ├── es/
│ │ └── welcome.mp3
│ └── ja/
│ └── welcome.mp3
└── videos/
├── en/
│ └── tutorial.mp4
└── es/
└── tutorial.mp4
Update pubspec.yaml:
flutter:
assets:
- assets/images/common/
- assets/images/en/
- assets/images/es/
- assets/images/ar/
- assets/images/ja/
- assets/audio/en/
- assets/audio/es/
- assets/audio/ja/
- assets/videos/en/
- assets/videos/es/
Localized Asset Service
Create a service to resolve locale-specific assets:
// lib/services/localized_asset_service.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class LocalizedAssetService {
static const _fallbackLocale = 'en';
// Cache for asset existence checks
static final Map<String, bool> _assetExistsCache = {};
/// Get localized image path with fallback
static String imagePath(
BuildContext context,
String imageName, {
String? locale,
}) {
final effectiveLocale = locale ?? Localizations.localeOf(context).languageCode;
return _getLocalizedPath('images', imageName, effectiveLocale);
}
/// Get localized audio path with fallback
static String audioPath(
BuildContext context,
String audioName, {
String? locale,
}) {
final effectiveLocale = locale ?? Localizations.localeOf(context).languageCode;
return _getLocalizedPath('audio', audioName, effectiveLocale);
}
/// Get localized video path with fallback
static String videoPath(
BuildContext context,
String videoName, {
String? locale,
}) {
final effectiveLocale = locale ?? Localizations.localeOf(context).languageCode;
return _getLocalizedPath('videos', videoName, effectiveLocale);
}
static String _getLocalizedPath(
String assetType,
String fileName,
String locale,
) {
// Try locale-specific first
final localizedPath = 'assets/$assetType/$locale/$fileName';
if (_checkAssetExists(localizedPath)) {
return localizedPath;
}
// Try fallback locale
final fallbackPath = 'assets/$assetType/$_fallbackLocale/$fileName';
if (_checkAssetExists(fallbackPath)) {
return fallbackPath;
}
// Try common folder
final commonPath = 'assets/$assetType/common/$fileName';
return commonPath;
}
static bool _checkAssetExists(String path) {
if (_assetExistsCache.containsKey(path)) {
return _assetExistsCache[path]!;
}
// In production, you'd pre-compute this list during build
// For now, assume locale folders contain the asset
final exists = true; // Simplified
_assetExistsCache[path] = exists;
return exists;
}
/// Preload assets for a locale (call during splash screen)
static Future<void> preloadLocaleAssets(String locale) async {
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final assets = manifest.listAssets();
final localeAssets = assets.where(
(path) => path.contains('/$locale/'),
);
for (final asset in localeAssets) {
if (asset.endsWith('.png') || asset.endsWith('.jpg')) {
// Preload images
await rootBundle.load(asset);
}
}
}
}
Localized Image Widget
Create a reusable widget for localized images:
// lib/widgets/localized_image.dart
import 'package:flutter/material.dart';
import '../services/localized_asset_service.dart';
class LocalizedImage extends StatelessWidget {
final String name;
final double? width;
final double? height;
final BoxFit? fit;
final Color? color;
final String? semanticLabel;
const LocalizedImage({
super.key,
required this.name,
this.width,
this.height,
this.fit,
this.color,
this.semanticLabel,
});
@override
Widget build(BuildContext context) {
final path = LocalizedAssetService.imagePath(context, name);
return Image.asset(
path,
width: width,
height: height,
fit: fit,
color: color,
semanticsLabel: semanticLabel,
errorBuilder: (context, error, stackTrace) {
// Fallback to common asset or placeholder
return Image.asset(
'assets/images/common/placeholder.png',
width: width,
height: height,
fit: fit,
);
},
);
}
}
// RTL-aware localized image
class DirectionalLocalizedImage extends StatelessWidget {
final String name;
final String? rtlName; // Optional different image for RTL
final double? width;
final double? height;
final BoxFit? fit;
final bool mirrorForRtl;
const DirectionalLocalizedImage({
super.key,
required this.name,
this.rtlName,
this.width,
this.height,
this.fit,
this.mirrorForRtl = false,
});
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
final effectiveName = isRtl && rtlName != null ? rtlName! : name;
final path = LocalizedAssetService.imagePath(context, effectiveName);
Widget image = Image.asset(
path,
width: width,
height: height,
fit: fit,
);
// Mirror image for RTL if requested
if (isRtl && mirrorForRtl) {
image = Transform.flip(
flipX: true,
child: image,
);
}
return image;
}
}
// Usage
LocalizedImage(
name: 'onboarding_1.png',
width: 300,
height: 200,
fit: BoxFit.cover,
semanticLabel: l10n.onboardingImageDescription,
)
DirectionalLocalizedImage(
name: 'arrow_forward.png',
mirrorForRtl: true, // Arrow flips in RTL
)
Font Localization
Different scripts require appropriate fonts:
// lib/theme/localized_typography.dart
import 'package:flutter/material.dart';
class LocalizedTypography {
static const _arabicFontFamily = 'NotoSansArabic';
static const _cjkFontFamily = 'NotoSansCJK';
static const _devanagariFontFamily = 'NotoSansDevanagari';
static const _defaultFontFamily = 'Roboto';
static String getFontFamily(Locale locale) {
switch (locale.languageCode) {
case 'ar':
case 'fa':
case 'ur':
return _arabicFontFamily;
case 'zh':
case 'ja':
case 'ko':
return _cjkFontFamily;
case 'hi':
case 'mr':
case 'ne':
return _devanagariFontFamily;
default:
return _defaultFontFamily;
}
}
static TextTheme getLocalizedTextTheme(Locale locale, TextTheme base) {
final fontFamily = getFontFamily(locale);
return base.copyWith(
displayLarge: base.displayLarge?.copyWith(fontFamily: fontFamily),
displayMedium: base.displayMedium?.copyWith(fontFamily: fontFamily),
displaySmall: base.displaySmall?.copyWith(fontFamily: fontFamily),
headlineLarge: base.headlineLarge?.copyWith(fontFamily: fontFamily),
headlineMedium: base.headlineMedium?.copyWith(fontFamily: fontFamily),
headlineSmall: base.headlineSmall?.copyWith(fontFamily: fontFamily),
titleLarge: base.titleLarge?.copyWith(fontFamily: fontFamily),
titleMedium: base.titleMedium?.copyWith(fontFamily: fontFamily),
titleSmall: base.titleSmall?.copyWith(fontFamily: fontFamily),
bodyLarge: base.bodyLarge?.copyWith(fontFamily: fontFamily),
bodyMedium: base.bodyMedium?.copyWith(fontFamily: fontFamily),
bodySmall: base.bodySmall?.copyWith(fontFamily: fontFamily),
labelLarge: base.labelLarge?.copyWith(fontFamily: fontFamily),
labelMedium: base.labelMedium?.copyWith(fontFamily: fontFamily),
labelSmall: base.labelSmall?.copyWith(fontFamily: fontFamily),
);
}
static double getLineHeight(Locale locale) {
// Some scripts need more line height
switch (locale.languageCode) {
case 'ar':
case 'fa':
return 1.6;
case 'th':
return 1.8; // Thai has tall characters
case 'zh':
case 'ja':
case 'ko':
return 1.5;
default:
return 1.4;
}
}
}
// Apply in theme
class AppTheme {
static ThemeData getTheme(Locale locale, Brightness brightness) {
final baseTheme = brightness == Brightness.light
? ThemeData.light()
: ThemeData.dark();
final textTheme = LocalizedTypography.getLocalizedTextTheme(
locale,
baseTheme.textTheme,
);
return baseTheme.copyWith(
textTheme: textTheme,
);
}
}
// Usage in MaterialApp
BlocBuilder<LocaleBloc, LocaleState>(
builder: (context, state) {
return MaterialApp(
locale: state.locale,
theme: AppTheme.getTheme(state.locale, Brightness.light),
darkTheme: AppTheme.getTheme(state.locale, Brightness.dark),
// ...
);
},
)
Register fonts in pubspec.yaml:
flutter:
fonts:
- family: Roboto
fonts:
- asset: assets/fonts/Roboto/Roboto-Regular.ttf
- asset: assets/fonts/Roboto/Roboto-Bold.ttf
weight: 700
- family: NotoSansArabic
fonts:
- asset: assets/fonts/NotoSansArabic/NotoSansArabic-Regular.ttf
- asset: assets/fonts/NotoSansArabic/NotoSansArabic-Bold.ttf
weight: 700
- family: NotoSansCJK
fonts:
- asset: assets/fonts/NotoSansCJK/NotoSansCJKsc-Regular.otf
- asset: assets/fonts/NotoSansCJK/NotoSansCJKsc-Bold.otf
weight: 700
- family: NotoSansDevanagari
fonts:
- asset: assets/fonts/NotoSansDevanagari/NotoSansDevanagari-Regular.ttf
- asset: assets/fonts/NotoSansDevanagari/NotoSansDevanagari-Bold.ttf
weight: 700
Audio Localization
For voice prompts and spoken content:
// lib/services/localized_audio_service.dart
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
class LocalizedAudioService {
final AudioPlayer _player = AudioPlayer();
String _currentLocale = 'en';
void updateLocale(Locale locale) {
_currentLocale = locale.languageCode;
}
Future<void> playWelcome() async {
await _playLocalizedAudio('welcome.mp3');
}
Future<void> playNotification() async {
await _playLocalizedAudio('notification.mp3');
}
Future<void> playError() async {
await _playLocalizedAudio('error.mp3');
}
Future<void> _playLocalizedAudio(String fileName) async {
// Try locale-specific audio
String path = 'assets/audio/$_currentLocale/$fileName';
try {
await _player.play(AssetSource(path.replaceFirst('assets/', '')));
} catch (_) {
// Fallback to English
path = 'assets/audio/en/$fileName';
try {
await _player.play(AssetSource(path.replaceFirst('assets/', '')));
} catch (_) {
// No audio available
debugPrint('Audio not found: $fileName');
}
}
}
Future<void> speak(String text, Locale locale) async {
// For TTS, use flutter_tts package
// final tts = FlutterTts();
// await tts.setLanguage(locale.languageCode);
// await tts.speak(text);
}
void dispose() {
_player.dispose();
}
}
// Provider setup
class LocalizedAudioProvider extends InheritedWidget {
final LocalizedAudioService audioService;
const LocalizedAudioProvider({
super.key,
required this.audioService,
required super.child,
});
static LocalizedAudioService of(BuildContext context) {
final provider = context
.dependOnInheritedWidgetOfExactType<LocalizedAudioProvider>();
return provider!.audioService;
}
@override
bool updateShouldNotify(LocalizedAudioProvider oldWidget) {
return audioService != oldWidget.audioService;
}
}
// Usage
class WelcomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
LocalizedAudioProvider.of(context).playWelcome();
},
child: Text(l10n.playWelcome),
);
}
}
Video Localization
For localized video content:
// lib/widgets/localized_video_player.dart
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import '../services/localized_asset_service.dart';
class LocalizedVideoPlayer extends StatefulWidget {
final String videoName;
final bool autoPlay;
final bool showControls;
const LocalizedVideoPlayer({
super.key,
required this.videoName,
this.autoPlay = false,
this.showControls = true,
});
@override
State<LocalizedVideoPlayer> createState() => _LocalizedVideoPlayerState();
}
class _LocalizedVideoPlayerState extends State<LocalizedVideoPlayer> {
VideoPlayerController? _controller;
bool _isInitialized = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_initializeVideo();
}
Future<void> _initializeVideo() async {
final path = LocalizedAssetService.videoPath(context, widget.videoName);
_controller?.dispose();
_controller = VideoPlayerController.asset(path);
try {
await _controller!.initialize();
setState(() => _isInitialized = true);
if (widget.autoPlay) {
_controller!.play();
}
} catch (e) {
debugPrint('Failed to load video: $e');
// Try fallback
await _loadFallbackVideo();
}
}
Future<void> _loadFallbackVideo() async {
final fallbackPath = 'assets/videos/en/${widget.videoName}';
_controller = VideoPlayerController.asset(fallbackPath);
try {
await _controller!.initialize();
setState(() => _isInitialized = true);
} catch (e) {
debugPrint('Fallback video also failed: $e');
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_isInitialized || _controller == null) {
return const Center(child: CircularProgressIndicator());
}
return AspectRatio(
aspectRatio: _controller!.value.aspectRatio,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
VideoPlayer(_controller!),
if (widget.showControls) _buildControls(),
],
),
);
}
Widget _buildControls() {
return VideoProgressIndicator(
_controller!,
allowScrubbing: true,
colors: const VideoProgressColors(
playedColor: Colors.blue,
bufferedColor: Colors.grey,
backgroundColor: Colors.black26,
),
);
}
}
// Subtitle support for videos
class LocalizedVideoWithSubtitles extends StatefulWidget {
final String videoName;
final String? subtitleName;
const LocalizedVideoWithSubtitles({
super.key,
required this.videoName,
this.subtitleName,
});
@override
State<LocalizedVideoWithSubtitles> createState() =>
_LocalizedVideoWithSubtitlesState();
}
class _LocalizedVideoWithSubtitlesState
extends State<LocalizedVideoWithSubtitles> {
VideoPlayerController? _controller;
List<Caption> _captions = [];
@override
void didChangeDependencies() {
super.didChangeDependencies();
_initializeVideoWithSubtitles();
}
Future<void> _initializeVideoWithSubtitles() async {
final locale = Localizations.localeOf(context).languageCode;
// Initialize video
final videoPath = 'assets/videos/en/${widget.videoName}'; // Base video
_controller = VideoPlayerController.asset(videoPath);
await _controller!.initialize();
// Load localized subtitles
final subtitlePath = 'assets/subtitles/$locale/${widget.subtitleName ?? widget.videoName.replaceAll('.mp4', '.vtt')}';
try {
_captions = await _loadSubtitles(subtitlePath);
} catch (_) {
// No subtitles for this locale
}
setState(() {});
}
Future<List<Caption>> _loadSubtitles(String path) async {
// Parse VTT or SRT file
// Implementation depends on subtitle format
return [];
}
@override
Widget build(BuildContext context) {
if (_controller == null || !_controller!.value.isInitialized) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
VideoPlayer(_controller!),
if (_captions.isNotEmpty)
ClosedCaption(
text: _controller!.value.caption.text,
textStyle: const TextStyle(
color: Colors.white,
backgroundColor: Colors.black54,
),
),
],
);
}
}
Icon Localization
Some icons have cultural meanings that vary:
// lib/widgets/localized_icon.dart
import 'package:flutter/material.dart';
class LocalizedIcon extends StatelessWidget {
final IconData icon;
final IconData? rtlIcon;
final IconData? arabicIcon;
final double? size;
final Color? color;
const LocalizedIcon({
super.key,
required this.icon,
this.rtlIcon,
this.arabicIcon,
this.size,
this.color,
});
@override
Widget build(BuildContext context) {
final locale = Localizations.localeOf(context);
final isRtl = Directionality.of(context) == TextDirection.rtl;
IconData effectiveIcon = icon;
// Arabic-specific icon
if (locale.languageCode == 'ar' && arabicIcon != null) {
effectiveIcon = arabicIcon!;
}
// RTL icon
else if (isRtl && rtlIcon != null) {
effectiveIcon = rtlIcon!;
}
return Icon(
effectiveIcon,
size: size,
color: color,
);
}
}
// Culturally adapted icons
class CulturalIcons {
static const Map<String, Map<String, IconData>> _culturalMappings = {
'mail': {
'default': Icons.mail,
'ja': Icons.mail_outline, // Japan prefers outlined
},
'home': {
'default': Icons.home,
// Some cultures might prefer different house styles
},
'currency': {
'default': Icons.attach_money,
'eu': Icons.euro,
'gb': Icons.currency_pound,
'jp': Icons.currency_yen,
},
};
static IconData get(String name, Locale locale) {
final mapping = _culturalMappings[name];
if (mapping == null) return Icons.help_outline;
return mapping[locale.languageCode] ??
mapping[locale.countryCode] ??
mapping['default'] ??
Icons.help_outline;
}
}
// Usage
Icon(CulturalIcons.get('currency', Localizations.localeOf(context)))
Lazy Loading Locale Assets
For large apps, load assets on demand:
// lib/services/locale_asset_loader.dart
import 'package:flutter/services.dart';
class LocaleAssetLoader {
static final Map<String, bool> _loadedLocales = {};
static Future<void> ensureLocaleAssetsLoaded(String locale) async {
if (_loadedLocales[locale] == true) return;
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final allAssets = manifest.listAssets();
final localeAssets = allAssets.where(
(path) => path.contains('/$locale/'),
);
// Preload critical assets
for (final asset in localeAssets) {
if (_isCriticalAsset(asset)) {
await rootBundle.load(asset);
}
}
_loadedLocales[locale] = true;
}
static bool _isCriticalAsset(String path) {
// Define which assets are critical for immediate display
return path.contains('onboarding') ||
path.contains('splash') ||
path.contains('logo');
}
static Future<int> getLocaleAssetSize(String locale) async {
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final allAssets = manifest.listAssets();
final localeAssets = allAssets.where(
(path) => path.contains('/$locale/'),
);
int totalSize = 0;
for (final asset in localeAssets) {
try {
final data = await rootBundle.load(asset);
totalSize += data.lengthInBytes;
} catch (_) {}
}
return totalSize;
}
}
// Use during locale switch
void onLocaleChanged(Locale newLocale) async {
// Show loading indicator
showLoadingOverlay();
// Load assets for new locale
await LocaleAssetLoader.ensureLocaleAssetsLoaded(newLocale.languageCode);
// Hide loading indicator
hideLoadingOverlay();
}
Best Practices
1. Use Consistent Naming
assets/images/en/onboarding_step_1.png
assets/images/es/onboarding_step_1.png
assets/images/ar/onboarding_step_1.png
2. Optimize Asset Sizes
// Provide multiple resolutions
assets/images/en/1.5x/banner.png
assets/images/en/2.0x/banner.png
assets/images/en/3.0x/banner.png
3. Document Cultural Requirements
# assets/README.md
# Asset Localization Guide
## Images requiring localization:
- onboarding_*.png - Contains text overlays
- banner_promo.png - Marketing text
- tutorial_*.png - Step-by-step instructions
## Cultural considerations:
- AR: RTL layout, Islamic patterns preferred
- JP: Softer colors, outlined icons
- CN: Red for positive, avoid number 4
4. Test All Locales
testWidgets('all localized assets exist', (tester) async {
for (final locale in supportedLocales) {
for (final asset in requiredAssets) {
final path = 'assets/images/${locale.languageCode}/$asset';
expect(
() async => await rootBundle.load(path),
returnsNormally,
reason: 'Missing: $path',
);
}
}
});
Conclusion
Asset localization is crucial for delivering culturally appropriate experiences. By organizing assets by locale, creating helper services for resolution, and implementing proper fallbacks, you ensure users always see relevant content in their language and cultural context.
Key takeaways:
- Organize assets in locale-specific folders
- Create reusable widgets for localized images, audio, and video
- Use appropriate fonts for different scripts
- Implement fallback chains for missing assets
- Lazy load assets to optimize app size
For managing your string translations alongside localized assets, FlutterLocalisation provides a comprehensive platform that tracks all your localization needs in one place.