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.