← Back to Blog

Flutter Dynamic Asset Localization: Images, Fonts, Audio, and Videos per Locale

flutterlocalizationassetsimagesfontsaudioi18n

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.