← Back to Blog

Flutter Localization Debugging: Tools and Techniques for Finding i18n Issues

flutterlocalizationdebuggingdevtoolstestingtroubleshooting

Flutter Localization Debugging: Tools and Techniques to Fix Translation Issues

Localization bugs can be tricky to track down. This guide covers debugging tools, techniques, and common issues to help you find and fix translation problems quickly.

Common Localization Issues

Before diving into tools, let's identify what we're looking for:

  1. Missing translations - Keys that return null or fallback
  2. Wrong locale - App not detecting or applying correct locale
  3. Placeholder errors - Missing or wrong placeholder values
  4. Format issues - Dates, numbers, currencies displaying wrong
  5. RTL layout bugs - Incorrect text direction or alignment
  6. Build errors - ARB file syntax or generation issues

Built-in Debugging Tools

1. Flutter DevTools Locale Inspector

Flutter DevTools includes locale information:

// Enable verbose logging for localization
void main() {
  debugPrint('System locale: ${WidgetsBinding.instance.platformDispatcher.locale}');
  debugPrint('System locales: ${WidgetsBinding.instance.platformDispatcher.locales}');
  runApp(const MyApp());
}

2. Debug Overlay for Current Locale

Create a debug overlay showing the current locale:

// lib/widgets/debug/locale_debug_overlay.dart
import 'package:flutter/material.dart';

class LocaleDebugOverlay extends StatelessWidget {
  final Widget child;
  final bool enabled;

  const LocaleDebugOverlay({
    super.key,
    required this.child,
    this.enabled = true,
  });

  @override
  Widget build(BuildContext context) {
    if (!enabled) return child;

    return Stack(
      children: [
        child,
        Positioned(
          top: 50,
          right: 10,
          child: _LocaleInfoBanner(),
        ),
      ],
    );
  }
}

class _LocaleInfoBanner extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final locale = Localizations.localeOf(context);
    final textDirection = Directionality.of(context);

    return Material(
      color: Colors.black87,
      borderRadius: BorderRadius.circular(8),
      child: Padding(
        padding: const EdgeInsets.all(8),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              'Locale: ${locale.toLanguageTag()}',
              style: const TextStyle(color: Colors.white, fontSize: 12),
            ),
            Text(
              'Direction: ${textDirection.name}',
              style: const TextStyle(color: Colors.white, fontSize: 12),
            ),
          ],
        ),
      ),
    );
  }
}

// Usage:
LocaleDebugOverlay(
  enabled: kDebugMode,
  child: MyApp(),
)

3. Translation Key Highlighter

Highlight missing or fallback translations:

// lib/debug/translation_highlighter.dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class DebugLocalizations {
  final AppLocalizations _l10n;
  final bool highlightMissing;

  DebugLocalizations(this._l10n, {this.highlightMissing = true});

  String get(String key, String value) {
    if (value.isEmpty || value == key) {
      if (highlightMissing) {
        return '⚠️ [$key]';
      }
      debugPrint('Missing translation: $key');
    }
    return value;
  }
}

// Extension for easier access
extension DebugL10n on BuildContext {
  DebugLocalizations get debugL10n {
    return DebugLocalizations(
      AppLocalizations.of(this)!,
      highlightMissing: true,
    );
  }
}

VS Code Debugging

launch.json Configuration

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Flutter (English)",
      "type": "dart",
      "request": "launch",
      "program": "lib/main.dart",
      "args": ["--dart-define=LOCALE=en"]
    },
    {
      "name": "Flutter (Spanish)",
      "type": "dart",
      "request": "launch",
      "program": "lib/main.dart",
      "args": ["--dart-define=LOCALE=es"]
    },
    {
      "name": "Flutter (Arabic RTL)",
      "type": "dart",
      "request": "launch",
      "program": "lib/main.dart",
      "args": ["--dart-define=LOCALE=ar"]
    }
  ]
}

Use in your app:

void main() {
  const forcedLocale = String.fromEnvironment('LOCALE');

  runApp(MyApp(
    forcedLocale: forcedLocale.isNotEmpty ? Locale(forcedLocale) : null,
  ));
}

VS Code Extensions

Install these helpful extensions:

  1. Flutter Intl - ARB file management
  2. ARB Editor - Syntax highlighting for ARB files
  3. i18n Ally - Translation management

Logging and Monitoring

Localization Logger

// lib/debug/localization_logger.dart
import 'package:flutter/foundation.dart';

class LocalizationLogger {
  static bool enabled = kDebugMode;

  static void logLocaleChange(Locale from, Locale to) {
    if (!enabled) return;
    debugPrint('🌍 Locale changed: ${from.toLanguageTag()}${to.toLanguageTag()}');
  }

  static void logTranslationAccess(String key, String? value) {
    if (!enabled) return;
    if (value == null || value.isEmpty) {
      debugPrint('⚠️ Missing translation: $key');
    } else {
      debugPrint('📝 Translation: $key = "$value"');
    }
  }

  static void logPlaceholderError(String key, String placeholder, dynamic value) {
    if (!enabled) return;
    debugPrint('❌ Placeholder error in "$key": {$placeholder} received ${value.runtimeType}');
  }

  static void logFormatError(String key, String format, dynamic value, Object error) {
    if (!enabled) return;
    debugPrint('❌ Format error in "$key": format=$format, value=$value, error=$error');
  }
}

Wrapper for Safe Translation Access

// lib/l10n/safe_localizations.dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../debug/localization_logger.dart';

class SafeLocalizations {
  final AppLocalizations _l10n;
  final String _currentLocale;

  SafeLocalizations(BuildContext context)
      : _l10n = AppLocalizations.of(context)!,
        _currentLocale = Localizations.localeOf(context).toLanguageTag();

  String get appTitle {
    final value = _l10n.appTitle;
    LocalizationLogger.logTranslationAccess('appTitle', value);
    return value;
  }

  String itemCount(int count) {
    try {
      final value = _l10n.itemCount(count);
      LocalizationLogger.logTranslationAccess('itemCount($count)', value);
      return value;
    } catch (e) {
      LocalizationLogger.logPlaceholderError('itemCount', 'count', count);
      return '$count items';
    }
  }

  // Add other translations with logging...
}

extension SafeL10n on BuildContext {
  SafeLocalizations get safeL10n => SafeLocalizations(this);
}

ARB File Validation

Validation Script

// tool/validate_arb.dart
import 'dart:convert';
import 'dart:io';

void main() async {
  final arbDir = Directory('lib/l10n');
  final files = arbDir.listSync().whereType<File>().where(
        (f) => f.path.endsWith('.arb'),
      );

  final errors = <String>[];
  final templateFile = files.firstWhere(
    (f) => f.path.contains('app_en.arb'),
  );
  final templateContent = jsonDecode(await templateFile.readAsString());
  final templateKeys = templateContent.keys
      .where((k) => !k.toString().startsWith('@'))
      .toSet();

  for (final file in files) {
    final content = await file.readAsString();

    // Check JSON validity
    try {
      final json = jsonDecode(content) as Map<String, dynamic>;

      // Check for missing keys
      final keys = json.keys
          .where((k) => !k.startsWith('@'))
          .toSet();

      final missingKeys = templateKeys.difference(keys);
      for (final key in missingKeys) {
        errors.add('${file.path}: Missing key "$key"');
      }

      // Check placeholders
      for (final entry in json.entries) {
        if (entry.key.startsWith('@')) continue;

        final value = entry.value.toString();
        final metadata = json['@${entry.key}'];

        // Find placeholders in value
        final placeholderRegex = RegExp(r'\{(\w+)\}');
        final foundPlaceholders = placeholderRegex
            .allMatches(value)
            .map((m) => m.group(1)!)
            .toSet();

        // Check against metadata
        if (metadata != null && metadata['placeholders'] != null) {
          final declaredPlaceholders =
              (metadata['placeholders'] as Map).keys.toSet();

          final undeclared = foundPlaceholders.difference(
            declaredPlaceholders.cast<String>(),
          );
          for (final p in undeclared) {
            errors.add('${file.path}: Undeclared placeholder {$p} in "${entry.key}"');
          }
        } else if (foundPlaceholders.isNotEmpty) {
          errors.add(
            '${file.path}: "${entry.key}" has placeholders but no @metadata',
          );
        }
      }
    } catch (e) {
      errors.add('${file.path}: Invalid JSON - $e');
    }
  }

  if (errors.isEmpty) {
    print('✅ All ARB files are valid');
  } else {
    print('❌ Found ${errors.length} errors:');
    for (final error in errors) {
      print('  - $error');
    }
    exit(1);
  }
}

Run with:

dart run tool/validate_arb.dart

Testing for Localization Issues

Unit Tests for Translations

// test/l10n/translations_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'dart:convert';
import 'dart:io';

void main() {
  group('ARB Files', () {
    late Map<String, dynamic> englishArb;
    late List<File> arbFiles;

    setUpAll(() async {
      final arbDir = Directory('lib/l10n');
      arbFiles = arbDir
          .listSync()
          .whereType<File>()
          .where((f) => f.path.endsWith('.arb'))
          .toList();

      final englishFile = arbFiles.firstWhere(
        (f) => f.path.contains('app_en.arb'),
      );
      englishArb = jsonDecode(await englishFile.readAsString());
    });

    test('all ARB files are valid JSON', () async {
      for (final file in arbFiles) {
        expect(
          () => jsonDecode(file.readAsStringSync()),
          returnsNormally,
          reason: '${file.path} should be valid JSON',
        );
      }
    });

    test('all ARB files have required keys', () async {
      final requiredKeys = englishArb.keys
          .where((k) => !k.startsWith('@') && !k.startsWith('@@'))
          .toList();

      for (final file in arbFiles) {
        final content = jsonDecode(await file.readAsString());

        for (final key in requiredKeys) {
          expect(
            content.containsKey(key),
            true,
            reason: '${file.path} should have key "$key"',
          );
        }
      }
    });

    test('no empty translations', () async {
      for (final file in arbFiles) {
        final content = jsonDecode(await file.readAsString()) as Map;

        for (final entry in content.entries) {
          if (entry.key.toString().startsWith('@')) continue;

          expect(
            entry.value.toString().trim().isNotEmpty,
            true,
            reason: '${file.path}: "${entry.key}" should not be empty',
          );
        }
      }
    });
  });
}

Widget Tests with Different Locales

// test/widgets/localized_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

void main() {
  Widget createTestWidget(Locale locale, Widget child) {
    return MaterialApp(
      locale: locale,
      supportedLocales: const [
        Locale('en'),
        Locale('es'),
        Locale('ar'),
      ],
      localizationsDelegates: const [
        AppLocalizations.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      home: child,
    );
  }

  group('Localized Widget Tests', () {
    testWidgets('displays English text', (tester) async {
      await tester.pumpWidget(createTestWidget(
        const Locale('en'),
        const MyWidget(),
      ));
      await tester.pumpAndSettle();

      expect(find.text('Welcome'), findsOneWidget);
    });

    testWidgets('displays Spanish text', (tester) async {
      await tester.pumpWidget(createTestWidget(
        const Locale('es'),
        const MyWidget(),
      ));
      await tester.pumpAndSettle();

      expect(find.text('Bienvenido'), findsOneWidget);
    });

    testWidgets('handles RTL for Arabic', (tester) async {
      await tester.pumpWidget(createTestWidget(
        const Locale('ar'),
        const MyWidget(),
      ));
      await tester.pumpAndSettle();

      final directionality = tester.widget<Directionality>(
        find.byType(Directionality).first,
      );
      expect(directionality.textDirection, TextDirection.rtl);
    });
  });
}

Debug Panel Widget

Create an in-app debug panel for localization:

// lib/widgets/debug/localization_debug_panel.dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class LocalizationDebugPanel extends StatelessWidget {
  const LocalizationDebugPanel({super.key});

  @override
  Widget build(BuildContext context) {
    final locale = Localizations.localeOf(context);
    final direction = Directionality.of(context);

    return Drawer(
      child: ListView(
        padding: EdgeInsets.zero,
        children: [
          DrawerHeader(
            decoration: BoxDecoration(
              color: Theme.of(context).primaryColor,
            ),
            child: const Text(
              'Localization Debug',
              style: TextStyle(color: Colors.white, fontSize: 24),
            ),
          ),
          _InfoTile(
            title: 'Current Locale',
            value: locale.toLanguageTag(),
          ),
          _InfoTile(
            title: 'Language Code',
            value: locale.languageCode,
          ),
          _InfoTile(
            title: 'Country Code',
            value: locale.countryCode ?? 'Not set',
          ),
          _InfoTile(
            title: 'Text Direction',
            value: direction.name.toUpperCase(),
          ),
          const Divider(),
          const Padding(
            padding: EdgeInsets.all(16),
            child: Text(
              'Quick Locale Switch',
              style: TextStyle(fontWeight: FontWeight.bold),
            ),
          ),
          _LocaleSwitchTile(locale: const Locale('en'), label: 'English'),
          _LocaleSwitchTile(locale: const Locale('es'), label: 'Spanish'),
          _LocaleSwitchTile(locale: const Locale('ar'), label: 'Arabic (RTL)'),
          _LocaleSwitchTile(locale: const Locale('ja'), label: 'Japanese'),
          const Divider(),
          ListTile(
            leading: const Icon(Icons.bug_report),
            title: const Text('Show Translation Keys'),
            trailing: Switch(
              value: false, // Connect to your debug state
              onChanged: (value) {
                // Toggle showing raw keys
              },
            ),
          ),
        ],
      ),
    );
  }
}

class _InfoTile extends StatelessWidget {
  final String title;
  final String value;

  const _InfoTile({required this.title, required this.value});

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(title),
      trailing: Text(
        value,
        style: const TextStyle(fontFamily: 'monospace'),
      ),
    );
  }
}

class _LocaleSwitchTile extends StatelessWidget {
  final Locale locale;
  final String label;

  const _LocaleSwitchTile({required this.locale, required this.label});

  @override
  Widget build(BuildContext context) {
    final currentLocale = Localizations.localeOf(context);
    final isSelected = currentLocale.languageCode == locale.languageCode;

    return ListTile(
      leading: Icon(
        isSelected ? Icons.check_circle : Icons.circle_outlined,
        color: isSelected ? Colors.green : null,
      ),
      title: Text(label),
      subtitle: Text(locale.toLanguageTag()),
      onTap: () {
        // Switch locale using your provider
        // context.read<LocaleProvider>().setLocale(locale);
      },
    );
  }
}

Common Issues and Solutions

Issue 1: Translations Not Updating

Problem: Changed ARB file but app shows old text.

Solution:

# Clean and regenerate
flutter clean
flutter pub get
flutter gen-l10n
flutter run

Issue 2: Null Returned for Translation

Problem: AppLocalizations.of(context) returns null.

Solution: Check your MaterialApp setup:

MaterialApp(
  localizationsDelegates: [
    AppLocalizations.delegate, // Must be present!
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
  ],
  supportedLocales: AppLocalizations.supportedLocales,
)

Issue 3: Placeholder Type Mismatch

Problem: Runtime error with placeholders.

Solution: Verify types match:

// ARB
"greeting": "Hello {name}",
"@greeting": {
  "placeholders": {
    "name": {"type": "String"}  // Must match Dart call
  }
}
// Dart - correct
l10n.greeting('John')

// Dart - wrong (passing int)
l10n.greeting(123)

Issue 4: Wrong Plural Form

Problem: Plural not working correctly.

Solution: Check ICU syntax:

// Correct
"items": "{count, plural, =0{No items} =1{1 item} other{{count} items}}"

// Wrong (missing 'other')
"items": "{count, plural, =0{No items} =1{1 item}}"

Conclusion

Effective localization debugging requires:

  1. Good logging - Know what's happening
  2. Visual indicators - See issues immediately
  3. Validation scripts - Catch issues early
  4. Comprehensive tests - Prevent regressions

Build these tools into your workflow and you'll catch translation bugs before they reach users.

Need help managing your translations? FlutterLocalisation provides validation, team collaboration, and ensures your ARB files stay consistent across all languages.