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
- Cache translations - Avoid repeated API calls for same content
- Show translation status - Let users know when content is translated
- Allow toggle - Users may want to see original text
- Handle failures gracefully - Show original if translation fails
- Consider privacy - Some users may not want messages translated
- 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
- Flutter Push Notifications Localization
- Flutter Localization Analytics
- Flutter Firebase Remote Translations
- Free ARB Editor - Manage chat UI translations