← Back to Blog

Flutter WebSocket Localization: Real-Time Multilingual Communication

flutterwebsocketreal-timechatlocalizationsocket

Flutter WebSocket Localization: Real-Time Multilingual Chat and Messaging

Build real-time multilingual applications with WebSocket in Flutter. This guide covers localized message handling, connection status, error messages, and live translation for chat apps.

Why WebSocket Apps Need Special Localization

WebSocket applications present unique challenges:

  • Real-time messages arrive in any language
  • Connection states need localized feedback
  • Error handling must be language-aware
  • Timestamps require locale formatting
  • Typing indicators vary by language

Setting Up Localized WebSocket Messages

Basic WebSocket Service with Localization

import 'package:web_socket_channel/web_socket_channel.dart';

class LocalizedWebSocketService {
  WebSocketChannel? _channel;
  final String _baseUrl;
  final Function(String) onLocalizedMessage;
  final Function(String) onStatusChange;

  LocalizedWebSocketService({
    required String baseUrl,
    required this.onLocalizedMessage,
    required this.onStatusChange,
  }) : _baseUrl = baseUrl;

  Future<void> connect(String locale) async {
    try {
      onStatusChange(_getConnectingMessage(locale));

      _channel = WebSocketChannel.connect(
        Uri.parse('$_baseUrl?locale=$locale'),
      );

      onStatusChange(_getConnectedMessage(locale));

      _channel!.stream.listen(
        (message) => _handleMessage(message, locale),
        onError: (error) => _handleError(error, locale),
        onDone: () => _handleDisconnect(locale),
      );
    } catch (e) {
      onStatusChange(_getConnectionErrorMessage(locale));
    }
  }

  String _getConnectingMessage(String locale) {
    final messages = {
      'en': 'Connecting...',
      'es': 'Conectando...',
      'fr': 'Connexion...',
      'de': 'Verbinden...',
      'ar': 'جارٍ الاتصال...',
      'ja': '接続中...',
    };
    return messages[locale] ?? messages['en']!;
  }

  String _getConnectedMessage(String locale) {
    final messages = {
      'en': 'Connected',
      'es': 'Conectado',
      'fr': 'Connecté',
      'de': 'Verbunden',
      'ar': 'متصل',
      'ja': '接続済み',
    };
    return messages[locale] ?? messages['en']!;
  }

  String _getConnectionErrorMessage(String locale) {
    final messages = {
      'en': 'Connection failed. Please try again.',
      'es': 'Error de conexión. Por favor, inténtelo de nuevo.',
      'fr': 'Échec de la connexion. Veuillez réessayer.',
      'de': 'Verbindung fehlgeschlagen. Bitte versuchen Sie es erneut.',
      'ar': 'فشل الاتصال. يرجى المحاولة مرة أخرى.',
      'ja': '接続に失敗しました。もう一度お試しください。',
    };
    return messages[locale] ?? messages['en']!;
  }
}

Localized Message Model

class LocalizedChatMessage {
  final String id;
  final String senderId;
  final String senderName;
  final String content;
  final String originalLocale;
  final Map<String, String> translations;
  final DateTime timestamp;
  final MessageType type;

  LocalizedChatMessage({
    required this.id,
    required this.senderId,
    required this.senderName,
    required this.content,
    required this.originalLocale,
    this.translations = const {},
    required this.timestamp,
    this.type = MessageType.text,
  });

  factory LocalizedChatMessage.fromJson(Map<String, dynamic> json) {
    return LocalizedChatMessage(
      id: json['id'],
      senderId: json['sender_id'],
      senderName: json['sender_name'],
      content: json['content'],
      originalLocale: json['original_locale'] ?? 'en',
      translations: Map<String, String>.from(json['translations'] ?? {}),
      timestamp: DateTime.parse(json['timestamp']),
      type: MessageType.values.firstWhere(
        (e) => e.name == json['type'],
        orElse: () => MessageType.text,
      ),
    );
  }

  /// Get content in preferred locale with fallback
  String getLocalizedContent(String preferredLocale) {
    if (originalLocale == preferredLocale) {
      return content;
    }
    return translations[preferredLocale] ?? content;
  }

  /// Format timestamp for locale
  String getFormattedTime(String locale) {
    final formatter = DateFormat.jm(locale);
    return formatter.format(timestamp.toLocal());
  }

  /// Get relative time (e.g., "2 minutes ago")
  String getRelativeTime(String locale, AppLocalizations l10n) {
    final now = DateTime.now();
    final difference = now.difference(timestamp);

    if (difference.inMinutes < 1) {
      return l10n.justNow;
    } else if (difference.inMinutes < 60) {
      return l10n.minutesAgo(difference.inMinutes);
    } else if (difference.inHours < 24) {
      return l10n.hoursAgo(difference.inHours);
    } else {
      return DateFormat.MMMd(locale).format(timestamp);
    }
  }
}

enum MessageType { text, image, file, system, typing }

Real-Time Translation Integration

Live Translation Service

class LiveTranslationService {
  final TranslationApi _api;
  final Map<String, String> _cache = {};

  LiveTranslationService(this._api);

  Future<String> translateMessage({
    required String text,
    required String fromLocale,
    required String toLocale,
  }) async {
    if (fromLocale == toLocale) return text;

    final cacheKey = '${fromLocale}_${toLocale}_${text.hashCode}';

    if (_cache.containsKey(cacheKey)) {
      return _cache[cacheKey]!;
    }

    try {
      final translated = await _api.translate(
        text: text,
        from: fromLocale,
        to: toLocale,
      );

      _cache[cacheKey] = translated;
      return translated;
    } catch (e) {
      // Return original on failure
      return text;
    }
  }

  /// Batch translate multiple messages
  Future<List<String>> translateBatch({
    required List<String> texts,
    required String fromLocale,
    required String toLocale,
  }) async {
    if (fromLocale == toLocale) return texts;

    final results = <String>[];
    final toTranslate = <int, String>{};

    // Check cache first
    for (var i = 0; i < texts.length; i++) {
      final cacheKey = '${fromLocale}_${toLocale}_${texts[i].hashCode}';
      if (_cache.containsKey(cacheKey)) {
        results.add(_cache[cacheKey]!);
      } else {
        toTranslate[i] = texts[i];
        results.add(''); // Placeholder
      }
    }

    // Translate missing
    if (toTranslate.isNotEmpty) {
      final translated = await _api.translateBatch(
        texts: toTranslate.values.toList(),
        from: fromLocale,
        to: toLocale,
      );

      var j = 0;
      for (final i in toTranslate.keys) {
        results[i] = translated[j];
        final cacheKey = '${fromLocale}_${toLocale}_${texts[i].hashCode}';
        _cache[cacheKey] = translated[j];
        j++;
      }
    }

    return results;
  }
}

Auto-Translate Chat Widget

class AutoTranslateChatBubble extends StatefulWidget {
  final LocalizedChatMessage message;
  final String userLocale;
  final bool isMe;

  const AutoTranslateChatBubble({
    required this.message,
    required this.userLocale,
    required this.isMe,
  });

  @override
  State<AutoTranslateChatBubble> createState() => _AutoTranslateChatBubbleState();
}

class _AutoTranslateChatBubbleState extends State<AutoTranslateChatBubble> {
  bool _showOriginal = false;
  String? _translatedContent;
  bool _isTranslating = false;

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

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

    // Check if translation exists
    if (widget.message.translations.containsKey(widget.userLocale)) {
      setState(() {
        _translatedContent = widget.message.translations[widget.userLocale];
      });
      return;
    }

    // Request translation
    setState(() => _isTranslating = true);

    final service = context.read<LiveTranslationService>();
    final translated = await service.translateMessage(
      text: widget.message.content,
      fromLocale: widget.message.originalLocale,
      toLocale: widget.userLocale,
    );

    if (mounted) {
      setState(() {
        _translatedContent = translated;
        _isTranslating = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context)!;
    final displayContent = _showOriginal
        ? widget.message.content
        : (_translatedContent ?? widget.message.content);

    final needsTranslation = widget.message.originalLocale != widget.userLocale;

    return Align(
      alignment: widget.isMe ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: widget.isMe
              ? Theme.of(context).primaryColor
              : Colors.grey[200],
          borderRadius: BorderRadius.circular(16),
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            if (_isTranslating)
              Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  SizedBox(
                    width: 12,
                    height: 12,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  ),
                  SizedBox(width: 8),
                  Text(
                    l10n.translating,
                    style: TextStyle(
                      fontSize: 12,
                      fontStyle: FontStyle.italic,
                    ),
                  ),
                ],
              )
            else
              Text(
                displayContent,
                style: TextStyle(
                  color: widget.isMe ? Colors.white : Colors.black,
                ),
              ),

            SizedBox(height: 4),

            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(
                  widget.message.getFormattedTime(widget.userLocale),
                  style: TextStyle(
                    fontSize: 10,
                    color: widget.isMe
                        ? Colors.white70
                        : Colors.grey[600],
                  ),
                ),

                if (needsTranslation && _translatedContent != null) ...[
                  SizedBox(width: 8),
                  GestureDetector(
                    onTap: () => setState(() => _showOriginal = !_showOriginal),
                    child: Text(
                      _showOriginal ? l10n.showTranslation : l10n.showOriginal,
                      style: TextStyle(
                        fontSize: 10,
                        color: widget.isMe
                            ? Colors.white70
                            : Theme.of(context).primaryColor,
                        decoration: TextDecoration.underline,
                      ),
                    ),
                  ),
                ],
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Localized Connection States

Connection Status Widget

class LocalizedConnectionStatus extends StatelessWidget {
  final ConnectionState state;

  const LocalizedConnectionStatus({required this.state});

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

    return AnimatedContainer(
      duration: Duration(milliseconds: 300),
      padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      decoration: BoxDecoration(
        color: _getBackgroundColor(),
        borderRadius: BorderRadius.circular(20),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          _buildIcon(),
          SizedBox(width: 8),
          Text(
            _getMessage(l10n),
            style: TextStyle(
              color: Colors.white,
              fontWeight: FontWeight.w500,
            ),
          ),
        ],
      ),
    );
  }

  String _getMessage(AppLocalizations l10n) {
    switch (state) {
      case ConnectionState.connecting:
        return l10n.connectionConnecting;
      case ConnectionState.connected:
        return l10n.connectionConnected;
      case ConnectionState.disconnected:
        return l10n.connectionDisconnected;
      case ConnectionState.reconnecting:
        return l10n.connectionReconnecting;
      case ConnectionState.error:
        return l10n.connectionError;
    }
  }

  Color _getBackgroundColor() {
    switch (state) {
      case ConnectionState.connecting:
      case ConnectionState.reconnecting:
        return Colors.orange;
      case ConnectionState.connected:
        return Colors.green;
      case ConnectionState.disconnected:
      case ConnectionState.error:
        return Colors.red;
    }
  }

  Widget _buildIcon() {
    switch (state) {
      case ConnectionState.connecting:
      case ConnectionState.reconnecting:
        return SizedBox(
          width: 16,
          height: 16,
          child: CircularProgressIndicator(
            strokeWidth: 2,
            valueColor: AlwaysStoppedAnimation(Colors.white),
          ),
        );
      case ConnectionState.connected:
        return Icon(Icons.check_circle, color: Colors.white, size: 16);
      case ConnectionState.disconnected:
        return Icon(Icons.cloud_off, color: Colors.white, size: 16);
      case ConnectionState.error:
        return Icon(Icons.error, color: Colors.white, size: 16);
    }
  }
}

enum ConnectionState {
  connecting,
  connected,
  disconnected,
  reconnecting,
  error,
}

Localized Typing Indicators

Typing Indicator with Localization

class LocalizedTypingIndicator extends StatelessWidget {
  final List<String> typingUsers;
  final int maxDisplayNames;

  const LocalizedTypingIndicator({
    required this.typingUsers,
    this.maxDisplayNames = 2,
  });

  @override
  Widget build(BuildContext context) {
    if (typingUsers.isEmpty) return SizedBox.shrink();

    final l10n = AppLocalizations.of(context)!;

    return Padding(
      padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Row(
        children: [
          _TypingAnimation(),
          SizedBox(width: 8),
          Expanded(
            child: Text(
              _getTypingText(l10n),
              style: TextStyle(
                color: Colors.grey[600],
                fontStyle: FontStyle.italic,
              ),
            ),
          ),
        ],
      ),
    );
  }

  String _getTypingText(AppLocalizations l10n) {
    if (typingUsers.length == 1) {
      return l10n.userIsTyping(typingUsers.first);
    } else if (typingUsers.length == 2) {
      return l10n.twoUsersAreTyping(typingUsers[0], typingUsers[1]);
    } else {
      final displayNames = typingUsers.take(maxDisplayNames).join(', ');
      final othersCount = typingUsers.length - maxDisplayNames;
      return l10n.multipleUsersAreTyping(displayNames, othersCount);
    }
  }
}

class _TypingAnimation extends StatefulWidget {
  @override
  State<_TypingAnimation> createState() => _TypingAnimationState();
}

class _TypingAnimationState extends State<_TypingAnimation>
    with TickerProviderStateMixin {
  late List<AnimationController> _controllers;

  @override
  void initState() {
    super.initState();
    _controllers = List.generate(3, (index) {
      return AnimationController(
        duration: Duration(milliseconds: 600),
        vsync: this,
      )..repeat(reverse: true);
    });

    // Stagger animations
    for (var i = 0; i < _controllers.length; i++) {
      Future.delayed(Duration(milliseconds: i * 150), () {
        if (mounted) _controllers[i].forward();
      });
    }
  }

  @override
  void dispose() {
    for (final controller in _controllers) {
      controller.dispose();
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      children: List.generate(3, (index) {
        return AnimatedBuilder(
          animation: _controllers[index],
          builder: (context, child) {
            return Container(
              margin: EdgeInsets.symmetric(horizontal: 2),
              width: 8,
              height: 8 + (_controllers[index].value * 4),
              decoration: BoxDecoration(
                color: Colors.grey[400],
                borderRadius: BorderRadius.circular(4),
              ),
            );
          },
        );
      }),
    );
  }
}

ARB File Structure

{
  "@@locale": "en",

  "connectionConnecting": "Connecting...",
  "connectionConnected": "Connected",
  "connectionDisconnected": "Disconnected",
  "connectionReconnecting": "Reconnecting...",
  "connectionError": "Connection error",

  "translating": "Translating...",
  "showOriginal": "Show original",
  "showTranslation": "Show translation",

  "justNow": "Just now",
  "minutesAgo": "{count, plural, =1{1 minute ago} other{{count} minutes ago}}",
  "@minutesAgo": {
    "placeholders": {
      "count": {"type": "int"}
    }
  },
  "hoursAgo": "{count, plural, =1{1 hour ago} other{{count} hours ago}}",
  "@hoursAgo": {
    "placeholders": {
      "count": {"type": "int"}
    }
  },

  "userIsTyping": "{name} is typing...",
  "@userIsTyping": {
    "placeholders": {
      "name": {"type": "String"}
    }
  },
  "twoUsersAreTyping": "{name1} and {name2} are typing...",
  "@twoUsersAreTyping": {
    "placeholders": {
      "name1": {"type": "String"},
      "name2": {"type": "String"}
    }
  },
  "multipleUsersAreTyping": "{names} and {count, plural, =1{1 other} other{{count} others}} are typing...",
  "@multipleUsersAreTyping": {
    "placeholders": {
      "names": {"type": "String"},
      "count": {"type": "int"}
    }
  },

  "messageSent": "Sent",
  "messageDelivered": "Delivered",
  "messageRead": "Read",
  "messageFailed": "Failed to send",
  "messageRetry": "Tap to retry",

  "noMessages": "No messages yet",
  "startConversation": "Start the conversation!"
}

Best Practices

1. Cache Translations

class TranslationCache {
  final _cache = LruCache<String, String>(maxSize: 500);

  String? get(String key) => _cache.get(key);

  void put(String key, String value) => _cache.put(key, value);

  void clear() => _cache.clear();
}

2. Handle Offline Scenarios

class OfflineAwareWebSocket {
  Future<void> sendMessage(LocalizedChatMessage message) async {
    if (await _isOnline()) {
      await _sendToServer(message);
    } else {
      await _queueForLater(message);
      _showOfflineNotification();
    }
  }
}

3. Batch Translation Requests

// Instead of translating each message individually
// Batch them for efficiency
final batch = messages.where((m) => m.originalLocale != userLocale);
final translations = await translateBatch(batch.toList());

Conclusion

WebSocket localization in Flutter requires handling:

  • Real-time message translation
  • Localized connection states
  • Typing indicators in multiple languages
  • Relative time formatting
  • Offline message queuing

With proper caching and batch translation, you can build performant multilingual real-time apps.

Related Resources