← Back to Blog

Flutter Chat App Localization: Real-Time Multilingual Messaging

flutterchatmessagingreal-timetranslationnotificationslocalization

Flutter Chat App Localization: Real-Time Multilingual Messaging

Building a chat application that serves users worldwide requires sophisticated localization beyond simple UI translation. This guide covers real-time message translation, locale-aware formatting, and multilingual chat features that create seamless communication experiences.

Chat Localization Challenges

Chat apps present unique localization challenges:

  • Real-time translation - Messages between users speaking different languages
  • Timestamps - Relative times like "2 minutes ago" across locales
  • User preferences - Each user may prefer different languages
  • Message formatting - Links, mentions, emojis across locales
  • Push notifications - Localized alerts for incoming messages

Setting Up Chat Localization

Core Message Model

class ChatMessage {
  final String id;
  final String senderId;
  final String originalText;
  final String originalLocale;
  final Map<String, String> translations;
  final DateTime timestamp;
  final MessageType type;
  final Map<String, dynamic>? metadata;

  ChatMessage({
    required this.id,
    required this.senderId,
    required this.originalText,
    required this.originalLocale,
    this.translations = const {},
    required this.timestamp,
    this.type = MessageType.text,
    this.metadata,
  });

  String getDisplayText(String targetLocale) {
    if (targetLocale == originalLocale) {
      return originalText;
    }
    return translations[targetLocale] ?? originalText;
  }

  bool hasTranslation(String locale) {
    return locale == originalLocale || translations.containsKey(locale);
  }

  ChatMessage copyWithTranslation(String locale, String translation) {
    return ChatMessage(
      id: id,
      senderId: senderId,
      originalText: originalText,
      originalLocale: originalLocale,
      translations: {...translations, locale: translation},
      timestamp: timestamp,
      type: type,
      metadata: metadata,
    );
  }
}

enum MessageType {
  text,
  image,
  video,
  audio,
  file,
  location,
  contact,
  system,
}

Real-Time Translation Service

class ChatTranslationService {
  final TranslationApi _translationApi;
  final Map<String, String> _translationCache = {};

  ChatTranslationService(this._translationApi);

  Future<String> translateMessage(
    String text,
    String sourceLocale,
    String targetLocale,
  ) async {
    if (sourceLocale == targetLocale) return text;

    // Check cache first
    final cacheKey = _getCacheKey(text, sourceLocale, targetLocale);
    if (_translationCache.containsKey(cacheKey)) {
      return _translationCache[cacheKey]!;
    }

    try {
      final translation = await _translationApi.translate(
        text: text,
        source: sourceLocale,
        target: targetLocale,
      );

      _translationCache[cacheKey] = translation;
      return translation;
    } catch (e) {
      // Return original text if translation fails
      return text;
    }
  }

  Future<ChatMessage> translateMessageForUser(
    ChatMessage message,
    String userLocale,
  ) async {
    if (message.hasTranslation(userLocale)) {
      return message;
    }

    final translation = await translateMessage(
      message.originalText,
      message.originalLocale,
      userLocale,
    );

    return message.copyWithTranslation(userLocale, translation);
  }

  Stream<ChatMessage> translateMessageStream(
    Stream<ChatMessage> messages,
    String userLocale,
  ) async* {
    await for (final message in messages) {
      yield await translateMessageForUser(message, userLocale);
    }
  }

  String _getCacheKey(String text, String source, String target) {
    return '$source:$target:${text.hashCode}';
  }

  void clearCache() {
    _translationCache.clear();
  }
}

Localized Chat UI

Message Bubble with Translation Toggle

class LocalizedMessageBubble extends StatefulWidget {
  final ChatMessage message;
  final String currentUserLocale;
  final bool isOwnMessage;
  final ChatTranslationService translationService;

  const LocalizedMessageBubble({
    Key? key,
    required this.message,
    required this.currentUserLocale,
    required this.isOwnMessage,
    required this.translationService,
  }) : super(key: key);

  @override
  State<LocalizedMessageBubble> createState() => _LocalizedMessageBubbleState();
}

class _LocalizedMessageBubbleState extends State<LocalizedMessageBubble> {
  bool _showTranslation = true;
  bool _isTranslating = false;
  String? _translatedText;

  @override
  void initState() {
    super.initState();
    _loadTranslation();
  }

  Future<void> _loadTranslation() async {
    if (widget.message.originalLocale == widget.currentUserLocale) {
      return;
    }

    if (widget.message.hasTranslation(widget.currentUserLocale)) {
      setState(() {
        _translatedText = widget.message.getDisplayText(widget.currentUserLocale);
      });
      return;
    }

    setState(() => _isTranslating = true);

    try {
      final translation = await widget.translationService.translateMessage(
        widget.message.originalText,
        widget.message.originalLocale,
        widget.currentUserLocale,
      );

      if (mounted) {
        setState(() {
          _translatedText = translation;
          _isTranslating = false;
        });
      }
    } catch (e) {
      if (mounted) {
        setState(() => _isTranslating = false);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final isTranslated = widget.message.originalLocale != widget.currentUserLocale;

    return Align(
      alignment: widget.isOwnMessage
          ? Alignment.centerRight
          : Alignment.centerLeft,
      child: Container(
        margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
        padding: EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: widget.isOwnMessage
              ? Theme.of(context).primaryColor
              : Colors.grey[200],
          borderRadius: BorderRadius.circular(16),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: [
            // Message text
            Text(
              _showTranslation && _translatedText != null
                  ? _translatedText!
                  : widget.message.originalText,
              style: TextStyle(
                color: widget.isOwnMessage ? Colors.white : Colors.black87,
              ),
            ),

            SizedBox(height: 4),

            // Footer with timestamp and translation toggle
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                // Timestamp
                LocalizedTimestamp(
                  timestamp: widget.message.timestamp,
                  style: TextStyle(
                    fontSize: 12,
                    color: widget.isOwnMessage
                        ? Colors.white70
                        : Colors.black54,
                  ),
                ),

                // Translation indicator/toggle
                if (isTranslated) ...[
                  SizedBox(width: 8),
                  if (_isTranslating)
                    SizedBox(
                      width: 12,
                      height: 12,
                      child: CircularProgressIndicator(
                        strokeWidth: 2,
                        valueColor: AlwaysStoppedAnimation(
                          widget.isOwnMessage ? Colors.white70 : Colors.black54,
                        ),
                      ),
                    )
                  else
                    GestureDetector(
                      onTap: () => setState(() => _showTranslation = !_showTranslation),
                      child: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          Icon(
                            Icons.translate,
                            size: 14,
                            color: widget.isOwnMessage
                                ? Colors.white70
                                : Colors.black54,
                          ),
                          SizedBox(width: 4),
                          Text(
                            _showTranslation
                                ? l10n.showOriginal
                                : l10n.showTranslation,
                            style: TextStyle(
                              fontSize: 12,
                              color: widget.isOwnMessage
                                  ? Colors.white70
                                  : Colors.black54,
                            ),
                          ),
                        ],
                      ),
                    ),
                ],
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Relative Timestamp Widget

class LocalizedTimestamp extends StatelessWidget {
  final DateTime timestamp;
  final TextStyle? style;

  const LocalizedTimestamp({
    Key? key,
    required this.timestamp,
    this.style,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final locale = Localizations.localeOf(context);
    final formattedTime = _formatRelativeTime(timestamp, locale);

    return Text(formattedTime, style: style);
  }

  String _formatRelativeTime(DateTime time, Locale locale) {
    final now = DateTime.now();
    final difference = now.difference(time);

    final langCode = locale.languageCode;

    if (difference.inSeconds < 60) {
      return _getJustNow(langCode);
    } else if (difference.inMinutes < 60) {
      return _getMinutesAgo(difference.inMinutes, langCode);
    } else if (difference.inHours < 24) {
      return _getHoursAgo(difference.inHours, langCode);
    } else if (difference.inDays < 7) {
      return _getDaysAgo(difference.inDays, langCode);
    } else {
      return DateFormat.MMMd(locale.toString()).format(time);
    }
  }

  String _getJustNow(String langCode) {
    final translations = {
      'en': 'Just now',
      'es': 'Ahora mismo',
      'de': 'Gerade eben',
      'fr': 'À l\'instant',
      'zh': '刚刚',
      'ja': 'たった今',
      'ar': 'الآن',
    };
    return translations[langCode] ?? translations['en']!;
  }

  String _getMinutesAgo(int minutes, String langCode) {
    final templates = {
      'en': '$minutes min ago',
      'es': 'hace $minutes min',
      'de': 'vor $minutes Min.',
      'fr': 'il y a $minutes min',
      'zh': '$minutes分钟前',
      'ja': '$minutes分前',
      'ar': 'منذ $minutes دقيقة',
    };
    return templates[langCode] ?? templates['en']!;
  }

  String _getHoursAgo(int hours, String langCode) {
    final templates = {
      'en': '$hours hr ago',
      'es': 'hace $hours h',
      'de': 'vor $hours Std.',
      'fr': 'il y a $hours h',
      'zh': '$hours小时前',
      'ja': '$hours時間前',
      'ar': 'منذ $hours ساعة',
    };
    return templates[langCode] ?? templates['en']!;
  }

  String _getDaysAgo(int days, String langCode) {
    if (days == 1) {
      final templates = {
        'en': 'Yesterday',
        'es': 'Ayer',
        'de': 'Gestern',
        'fr': 'Hier',
        'zh': '昨天',
        'ja': '昨日',
        'ar': 'أمس',
      };
      return templates[langCode] ?? templates['en']!;
    }

    final templates = {
      'en': '$days days ago',
      'es': 'hace $days días',
      'de': 'vor $days Tagen',
      'fr': 'il y a $days jours',
      'zh': '$days天前',
      'ja': '$days日前',
      'ar': 'منذ $days أيام',
    };
    return templates[langCode] ?? templates['en']!;
  }
}

Chat Room Localization

Multi-Language Chat Room

class LocalizedChatRoom extends StatefulWidget {
  final String roomId;
  final String currentUserId;

  const LocalizedChatRoom({
    Key? key,
    required this.roomId,
    required this.currentUserId,
  }) : super(key: key);

  @override
  State<LocalizedChatRoom> createState() => _LocalizedChatRoomState();
}

class _LocalizedChatRoomState extends State<LocalizedChatRoom> {
  late final ChatService _chatService;
  late final ChatTranslationService _translationService;
  final TextEditingController _messageController = TextEditingController();
  final ScrollController _scrollController = ScrollController();

  List<ChatMessage> _messages = [];
  bool _autoTranslate = true;

  @override
  void initState() {
    super.initState();
    _chatService = context.read<ChatService>();
    _translationService = context.read<ChatTranslationService>();
    _loadMessages();
    _subscribeToMessages();
  }

  Future<void> _loadMessages() async {
    final messages = await _chatService.getMessages(widget.roomId);
    setState(() => _messages = messages);
  }

  void _subscribeToMessages() {
    _chatService.messageStream(widget.roomId).listen((message) {
      _handleNewMessage(message);
    });
  }

  Future<void> _handleNewMessage(ChatMessage message) async {
    final locale = Localizations.localeOf(context).languageCode;

    ChatMessage displayMessage = message;
    if (_autoTranslate && message.originalLocale != locale) {
      displayMessage = await _translationService.translateMessageForUser(
        message,
        locale,
      );
    }

    setState(() {
      _messages.add(displayMessage);
    });

    _scrollToBottom();
  }

  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  Future<void> _sendMessage() async {
    final text = _messageController.text.trim();
    if (text.isEmpty) return;

    final locale = Localizations.localeOf(context).languageCode;

    final message = ChatMessage(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      senderId: widget.currentUserId,
      originalText: text,
      originalLocale: locale,
      timestamp: DateTime.now(),
    );

    _messageController.clear();

    await _chatService.sendMessage(widget.roomId, message);
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.chat),
        actions: [
          // Auto-translate toggle
          IconButton(
            icon: Icon(
              _autoTranslate ? Icons.translate : Icons.translate_outlined,
            ),
            tooltip: _autoTranslate
                ? l10n.autoTranslateOn
                : l10n.autoTranslateOff,
            onPressed: () {
              setState(() => _autoTranslate = !_autoTranslate);
            },
          ),
          // Language selector
          PopupMenuButton<String>(
            icon: Icon(Icons.language),
            tooltip: l10n.selectLanguage,
            onSelected: (locale) {
              context.read<LocaleProvider>().setLocale(Locale(locale));
            },
            itemBuilder: (context) => [
              PopupMenuItem(value: 'en', child: Text('English')),
              PopupMenuItem(value: 'es', child: Text('Español')),
              PopupMenuItem(value: 'de', child: Text('Deutsch')),
              PopupMenuItem(value: 'fr', child: Text('Français')),
              PopupMenuItem(value: 'zh', child: Text('中文')),
              PopupMenuItem(value: 'ja', child: Text('日本語')),
              PopupMenuItem(value: 'ar', child: Text('العربية')),
            ],
          ),
        ],
      ),
      body: Column(
        children: [
          // Messages list
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              itemCount: _messages.length,
              itemBuilder: (context, index) {
                final message = _messages[index];
                return LocalizedMessageBubble(
                  message: message,
                  currentUserLocale: Localizations.localeOf(context).languageCode,
                  isOwnMessage: message.senderId == widget.currentUserId,
                  translationService: _translationService,
                );
              },
            ),
          ),

          // Input field
          _buildMessageInput(l10n),
        ],
      ),
    );
  }

  Widget _buildMessageInput(AppLocalizations l10n) {
    return Container(
      padding: EdgeInsets.all(8),
      decoration: BoxDecoration(
        color: Theme.of(context).cardColor,
        boxShadow: [
          BoxShadow(
            offset: Offset(0, -2),
            blurRadius: 4,
            color: Colors.black12,
          ),
        ],
      ),
      child: SafeArea(
        child: Row(
          children: [
            Expanded(
              child: TextField(
                controller: _messageController,
                decoration: InputDecoration(
                  hintText: l10n.typeMessage,
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(24),
                  ),
                  contentPadding: EdgeInsets.symmetric(
                    horizontal: 16,
                    vertical: 8,
                  ),
                ),
                textInputAction: TextInputAction.send,
                onSubmitted: (_) => _sendMessage(),
              ),
            ),
            SizedBox(width: 8),
            FloatingActionButton(
              mini: true,
              onPressed: _sendMessage,
              child: Icon(Icons.send),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _messageController.dispose();
    _scrollController.dispose();
    super.dispose();
  }
}

Push Notification Localization

Localized Chat Notifications

class ChatNotificationService {
  final FirebaseMessaging _messaging = FirebaseMessaging.instance;

  Future<void> initialize() async {
    // Request permission
    await _messaging.requestPermission();

    // Handle foreground messages
    FirebaseMessaging.onMessage.listen(_handleForegroundMessage);

    // Handle background messages
    FirebaseMessaging.onBackgroundMessage(_handleBackgroundMessage);
  }

  void _handleForegroundMessage(RemoteMessage message) {
    final locale = _getCurrentLocale();
    final notification = _buildLocalizedNotification(message, locale);
    _showLocalNotification(notification);
  }

  static Future<void> _handleBackgroundMessage(RemoteMessage message) async {
    // Background handler must be top-level function
    final prefs = await SharedPreferences.getInstance();
    final locale = prefs.getString('user_locale') ?? 'en';

    final notification = _buildLocalizedNotificationStatic(message, locale);
    await _showLocalNotificationStatic(notification);
  }

  String _getCurrentLocale() {
    // Get from provider or shared preferences
    return 'en';
  }

  LocalNotification _buildLocalizedNotification(
    RemoteMessage message,
    String locale,
  ) {
    final senderName = message.data['senderName'] ?? 'Unknown';
    final messageText = message.data['messageText'] ?? '';
    final originalLocale = message.data['originalLocale'] ?? 'en';

    // Get translated title
    final titles = {
      'en': 'New message from $senderName',
      'es': 'Nuevo mensaje de $senderName',
      'de': 'Neue Nachricht von $senderName',
      'fr': 'Nouveau message de $senderName',
      'zh': '来自$senderName的新消息',
      'ja': '$senderNameからの新着メッセージ',
      'ar': 'رسالة جديدة من $senderName',
    };

    // Get translated message preview or original
    String body = messageText;
    if (originalLocale != locale && message.data['translation_$locale'] != null) {
      body = message.data['translation_$locale'];
    }

    return LocalNotification(
      title: titles[locale] ?? titles['en']!,
      body: body,
      data: message.data,
    );
  }

  static LocalNotification _buildLocalizedNotificationStatic(
    RemoteMessage message,
    String locale,
  ) {
    // Static version for background handling
    final senderName = message.data['senderName'] ?? 'Unknown';
    final messageText = message.data['messageText'] ?? '';

    final titles = {
      'en': 'New message from $senderName',
      'es': 'Nuevo mensaje de $senderName',
      'de': 'Neue Nachricht von $senderName',
    };

    return LocalNotification(
      title: titles[locale] ?? titles['en']!,
      body: messageText,
      data: message.data,
    );
  }

  void _showLocalNotification(LocalNotification notification) {
    // Show using flutter_local_notifications
  }

  static Future<void> _showLocalNotificationStatic(
    LocalNotification notification,
  ) async {
    // Static version for background handling
  }
}

class LocalNotification {
  final String title;
  final String body;
  final Map<String, dynamic> data;

  LocalNotification({
    required this.title,
    required this.body,
    required this.data,
  });
}

Testing Chat Localization

Unit Tests

void main() {
  group('ChatTranslationService', () {
    late ChatTranslationService service;
    late MockTranslationApi mockApi;

    setUp(() {
      mockApi = MockTranslationApi();
      service = ChatTranslationService(mockApi);
    });

    test('returns original text when source equals target', () async {
      final result = await service.translateMessage('Hello', 'en', 'en');
      expect(result, 'Hello');
      verifyNever(mockApi.translate(any, any, any));
    });

    test('translates message when locales differ', () async {
      when(mockApi.translate(
        text: 'Hello',
        source: 'en',
        target: 'es',
      )).thenAnswer((_) async => 'Hola');

      final result = await service.translateMessage('Hello', 'en', 'es');
      expect(result, 'Hola');
    });

    test('caches translation results', () async {
      when(mockApi.translate(any, any, any)).thenAnswer((_) async => 'Hola');

      await service.translateMessage('Hello', 'en', 'es');
      await service.translateMessage('Hello', 'en', 'es');

      verify(mockApi.translate(any, any, any)).called(1);
    });
  });

  group('LocalizedTimestamp', () {
    testWidgets('shows "Just now" for recent messages', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: Locale('en'),
          home: LocalizedTimestamp(
            timestamp: DateTime.now().subtract(Duration(seconds: 30)),
          ),
        ),
      );

      expect(find.text('Just now'), findsOneWidget);
    });

    testWidgets('shows localized relative time', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          locale: Locale('es'),
          home: LocalizedTimestamp(
            timestamp: DateTime.now().subtract(Duration(minutes: 5)),
          ),
        ),
      );

      expect(find.text('hace 5 min'), findsOneWidget);
    });
  });
}

Best Practices

Chat Localization Checklist

  1. Cache translations - Avoid repeated API calls for same content
  2. Show translation status - Let users know when content is translated
  3. Allow toggle - Users may want to see original text
  4. Handle failures gracefully - Show original if translation fails
  5. Consider privacy - Some users may not want messages translated
  6. Optimize for real-time - Translation shouldn't block message display

Conclusion

Chat app localization goes beyond UI translation to include real-time message translation, locale-aware timestamps, and multilingual notifications. By implementing proper caching, user controls, and graceful fallbacks, you can create chat experiences that connect users across language barriers.

Related Resources