Flutter Wearable App Localization: Wear OS and watchOS Guide
Building a Flutter app for smartwatches? Wearable devices have unique localization challenges: tiny screens, glanceable content, and voice interactions. This guide covers everything you need to know about localizing Flutter apps for Wear OS and watchOS.
Wearable Localization Challenges
Smartwatch apps face unique constraints:
| Challenge | Impact on Localization |
|---|---|
| Tiny Screens | Text must be extremely concise |
| Glanceable UI | Users look for 2-3 seconds max |
| Voice Input | Need to handle spoken translations |
| Limited Fonts | Some scripts render poorly at small sizes |
| Battery Constraints | Heavy text processing drains battery |
Setting Up Flutter for Wearables
Wear OS Configuration
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
wear: ^1.1.0
flutter_localizations:
sdk: flutter
flutter:
uses-material-design: true
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest>
<uses-feature android:name="android.hardware.type.watch" />
<application
android:label="@string/app_name">
<!-- ... -->
</application>
</manifest>
Basic Localization Setup
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class WearApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: const [
Locale('en'),
Locale('es'),
Locale('ja'),
Locale('ar'),
],
home: const WatchFaceHome(),
);
}
}
Concise Translation Strategies
Keep It Short
Wearable screens demand brevity:
// ❌ Too long for watch
{
"stepGoalComplete": "Congratulations! You've reached your daily step goal of 10,000 steps!",
"heartRateWarning": "Your heart rate is currently elevated above normal levels"
}
// ✅ Watch-optimized
{
"stepGoalComplete": "Goal reached!",
"stepGoalCompleteDetail": "10K steps",
"heartRateWarning": "High HR",
"heartRateValue": "{bpm} bpm"
}
Create Watch-Specific Keys
// app_en.arb
{
"@@locale": "en",
"_WATCH_FACE": "=== Watch Face Strings ===",
"watchSteps": "{count}",
"@watchSteps": {
"description": "Step count on watch face - number only",
"placeholders": {
"count": {"type": "int", "example": "8432"}
}
},
"watchCalories": "{cal}",
"@watchCalories": {
"description": "Calories burned - number only"
},
"watchDistance": "{km}km",
"@watchDistance": {
"description": "Distance in kilometers, very short"
},
"_NOTIFICATIONS": "=== Notification Strings ===",
"notifCall": "Call",
"notifMessage": "Msg",
"notifReminder": "Reminder",
"_ACTIONS": "=== Quick Actions ===",
"actionDismiss": "Dismiss",
"actionReply": "Reply",
"actionCall": "Call",
"actionStart": "Start",
"actionStop": "Stop",
"actionPause": "Pause"
}
Abbreviation Guidelines by Language
class WatchTextHelper {
static String abbreviate(String text, String locale, int maxLength) {
if (text.length <= maxLength) return text;
// Language-specific abbreviation rules
switch (locale) {
case 'en':
return _abbreviateEnglish(text, maxLength);
case 'de':
return _abbreviateGerman(text, maxLength);
case 'ja':
return _abbreviateJapanese(text, maxLength);
default:
return text.substring(0, maxLength - 1) + '…';
}
}
static String _abbreviateEnglish(String text, int maxLength) {
// Common abbreviations
final abbrevs = {
'Monday': 'Mon',
'Tuesday': 'Tue',
'Wednesday': 'Wed',
'Thursday': 'Thu',
'Friday': 'Fri',
'Saturday': 'Sat',
'Sunday': 'Sun',
'minutes': 'min',
'seconds': 'sec',
'kilometers': 'km',
'calories': 'cal',
};
String result = text;
abbrevs.forEach((full, short) {
result = result.replaceAll(full, short);
});
return result.length <= maxLength
? result
: result.substring(0, maxLength - 1) + '…';
}
static String _abbreviateJapanese(String text, int maxLength) {
// Japanese can fit more meaning in fewer characters
// Often no abbreviation needed
return text.length <= maxLength
? text
: text.substring(0, maxLength);
}
static String _abbreviateGerman(String text, int maxLength) {
// German words are long, aggressive abbreviation needed
final abbrevs = {
'Montag': 'Mo',
'Dienstag': 'Di',
'Mittwoch': 'Mi',
'Donnerstag': 'Do',
'Freitag': 'Fr',
'Samstag': 'Sa',
'Sonntag': 'So',
'Minuten': 'Min',
'Sekunden': 'Sek',
'Kilometer': 'km',
'Kalorien': 'kcal',
};
String result = text;
abbrevs.forEach((full, short) {
result = result.replaceAll(full, short);
});
return result.length <= maxLength
? result
: result.substring(0, maxLength - 1) + '…';
}
}
Watch Face Complications
Complication Data Provider
class LocalizedComplicationData {
final AppLocalizations l10n;
LocalizedComplicationData(this.l10n);
/// Short text for complications (max ~7 chars)
String getShortSteps(int steps) {
if (steps >= 10000) {
return '${(steps / 1000).toStringAsFixed(1)}K';
}
return steps.toString();
}
/// Icon text (max ~3 chars)
String getIconText(String type) {
switch (type) {
case 'steps':
return '👟';
case 'heart':
return '❤️';
case 'battery':
return '🔋';
default:
return '';
}
}
/// Long text for detailed view
String getLongSteps(int steps) {
return l10n.stepsCount(steps);
}
}
RTL Support for Watch Faces
class WatchFaceLayout extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isRtl = Directionality.of(context) == TextDirection.rtl;
final l10n = AppLocalizations.of(context)!;
return Stack(
children: [
// Time - always centered
Center(
child: Text(
_formatTime(DateTime.now()),
style: const TextStyle(fontSize: 48),
),
),
// Steps - position based on direction
Positioned(
left: isRtl ? null : 16,
right: isRtl ? 16 : null,
bottom: 40,
child: Column(
crossAxisAlignment: isRtl
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
const Icon(Icons.directions_walk, size: 20),
Text(l10n.watchSteps(8432)),
],
),
),
// Heart rate - opposite side
Positioned(
right: isRtl ? null : 16,
left: isRtl ? 16 : null,
bottom: 40,
child: Column(
crossAxisAlignment: isRtl
? CrossAxisAlignment.start
: CrossAxisAlignment.end,
children: [
const Icon(Icons.favorite, size: 20),
Text(l10n.watchHeartRate(72)),
],
),
),
],
);
}
}
Voice Input Localization
Speech Recognition Setup
import 'package:speech_to_text/speech_to_text.dart';
class LocalizedVoiceInput {
final SpeechToText _speech = SpeechToText();
String _currentLocale = 'en_US';
Future<void> initialize(String locale) async {
final available = await _speech.initialize();
if (!available) return;
// Get available locales
final locales = await _speech.locales();
// Find best match for requested locale
_currentLocale = _findBestLocale(locale, locales);
}
String _findBestLocale(String requested, List<LocaleName> available) {
// Try exact match
for (final locale in available) {
if (locale.localeId == requested) {
return locale.localeId;
}
}
// Try language match
final lang = requested.split('_').first;
for (final locale in available) {
if (locale.localeId.startsWith(lang)) {
return locale.localeId;
}
}
// Fallback to first available
return available.first.localeId;
}
Future<String?> listen() async {
String? result;
await _speech.listen(
localeId: _currentLocale,
onResult: (speechResult) {
result = speechResult.recognizedWords;
},
listenFor: const Duration(seconds: 10),
);
return result;
}
}
Voice Command Localization
// app_en.arb
{
"voiceCommandStart": "Start workout",
"voiceCommandStop": "Stop",
"voiceCommandPause": "Pause",
"voiceCommandResume": "Resume",
"voiceCommandCheck": "Check my {metric}",
"@voiceCommandCheck": {
"placeholders": {
"metric": {"type": "String"}
}
}
}
// app_es.arb
{
"voiceCommandStart": "Iniciar entrenamiento",
"voiceCommandStop": "Parar",
"voiceCommandPause": "Pausar",
"voiceCommandResume": "Continuar",
"voiceCommandCheck": "Ver mi {metric}"
}
class VoiceCommandHandler {
final AppLocalizations l10n;
VoiceCommandHandler(this.l10n);
VoiceCommand? parseCommand(String input) {
final normalized = input.toLowerCase().trim();
// Check each command pattern
if (_matches(normalized, l10n.voiceCommandStart)) {
return VoiceCommand.startWorkout;
}
if (_matches(normalized, l10n.voiceCommandStop)) {
return VoiceCommand.stop;
}
if (_matches(normalized, l10n.voiceCommandPause)) {
return VoiceCommand.pause;
}
// Check parameterized commands
final metrics = ['heart rate', 'steps', 'calories'];
for (final metric in metrics) {
if (normalized.contains(metric) ||
normalized.contains(_localizedMetric(metric))) {
return VoiceCommand.checkMetric(metric);
}
}
return null;
}
bool _matches(String input, String command) {
return input.contains(command.toLowerCase());
}
}
Haptic Feedback Localization
Haptic patterns can convey meaning across languages:
class LocalizedHaptics {
static Future<void> success() async {
await HapticFeedback.mediumImpact();
await Future.delayed(const Duration(milliseconds: 100));
await HapticFeedback.mediumImpact();
}
static Future<void> error() async {
await HapticFeedback.heavyImpact();
await Future.delayed(const Duration(milliseconds: 50));
await HapticFeedback.heavyImpact();
await Future.delayed(const Duration(milliseconds: 50));
await HapticFeedback.heavyImpact();
}
static Future<void> notification() async {
await HapticFeedback.lightImpact();
}
static Future<void> goalReached() async {
// Celebratory pattern
for (int i = 0; i < 3; i++) {
await HapticFeedback.mediumImpact();
await Future.delayed(const Duration(milliseconds: 100));
}
}
}
Font Considerations
Readable Fonts for Small Screens
class WatchTypography {
static TextStyle getTimeStyle(String locale) {
// Some scripts need different fonts at small sizes
switch (locale) {
case 'ar':
return const TextStyle(
fontFamily: 'Noto Sans Arabic',
fontSize: 40,
fontWeight: FontWeight.w500,
);
case 'ja':
case 'zh':
case 'ko':
return const TextStyle(
fontFamily: 'Noto Sans CJK',
fontSize: 38,
fontWeight: FontWeight.w500,
);
case 'th':
return const TextStyle(
fontFamily: 'Noto Sans Thai',
fontSize: 36,
fontWeight: FontWeight.w500,
);
default:
return const TextStyle(
fontFamily: 'Roboto',
fontSize: 48,
fontWeight: FontWeight.w300,
);
}
}
static TextStyle getLabelStyle(String locale) {
final baseFontSize = _getBaseLabelSize(locale);
return TextStyle(
fontSize: baseFontSize,
fontWeight: FontWeight.w500,
letterSpacing: locale == 'ja' ? 0 : 0.5,
);
}
static double _getBaseLabelSize(String locale) {
// CJK characters are denser, can be smaller
if (['ja', 'zh', 'ko'].contains(locale)) {
return 12;
}
// Arabic needs slightly larger
if (locale == 'ar') {
return 14;
}
return 13;
}
}
Dynamic Font Sizing
class AdaptiveWatchText extends StatelessWidget {
final String text;
final double maxWidth;
final TextStyle baseStyle;
const AdaptiveWatchText({
required this.text,
required this.maxWidth,
required this.baseStyle,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
double fontSize = baseStyle.fontSize ?? 14;
// Measure text width
while (fontSize > 8) {
final textPainter = TextPainter(
text: TextSpan(
text: text,
style: baseStyle.copyWith(fontSize: fontSize),
),
maxLines: 1,
textDirection: Directionality.of(context),
)..layout();
if (textPainter.width <= maxWidth) {
break;
}
fontSize -= 1;
}
return Text(
text,
style: baseStyle.copyWith(fontSize: fontSize),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
);
}
}
Notification Localization
Wearable Notification Content
class WatchNotificationBuilder {
final AppLocalizations l10n;
WatchNotificationBuilder(this.l10n);
WatchNotification buildMessageNotification({
required String sender,
required String preview,
}) {
return WatchNotification(
// Very short title for glance
title: sender,
// Truncated preview
body: _truncate(preview, 50),
// Quick actions
actions: [
WatchAction(
label: l10n.actionReply,
icon: Icons.reply,
),
WatchAction(
label: l10n.actionDismiss,
icon: Icons.close,
),
],
);
}
WatchNotification buildReminderNotification({
required String title,
required DateTime time,
}) {
return WatchNotification(
title: l10n.notifReminder,
body: title,
subtitle: _formatWatchTime(time),
actions: [
WatchAction(
label: l10n.actionDismiss,
icon: Icons.check,
),
WatchAction(
label: l10n.actionSnooze,
icon: Icons.snooze,
),
],
);
}
String _truncate(String text, int maxLength) {
if (text.length <= maxLength) return text;
return '${text.substring(0, maxLength - 1)}…';
}
String _formatWatchTime(DateTime time) {
// Very short format for watch
final hour = time.hour.toString().padLeft(2, '0');
final minute = time.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
}
Testing Wearable Localization
Emulator Testing
void main() {
testWidgets('watch face renders RTL correctly', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('ar'),
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: const [Locale('ar')],
home: const MediaQuery(
// Simulate watch screen size
data: MediaQueryData(
size: Size(384, 384), // Wear OS large
),
child: WatchFaceLayout(),
),
),
);
// Verify RTL layout
final stepsWidget = find.text('٨٤٣٢'); // Arabic numerals
expect(stepsWidget, findsOneWidget);
});
testWidgets('text fits on small screen', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('de'), // German - long words
home: MediaQuery(
data: const MediaQueryData(
size: Size(320, 320), // Small watch
),
child: Builder(
builder: (context) {
final l10n = AppLocalizations.of(context)!;
return Center(
child: Text(l10n.workoutComplete),
);
},
),
),
),
);
// Verify no overflow
expect(tester.takeException(), isNull);
});
}
Real Device Testing Checklist
## Wearable Localization Testing Checklist
### Visual Testing
- [ ] Text fits on smallest supported watch (320x320)
- [ ] Text fits on largest supported watch (454x454)
- [ ] RTL languages display correctly
- [ ] CJK characters are readable
- [ ] Numbers format correctly per locale
### Interaction Testing
- [ ] Voice commands recognized in each language
- [ ] Quick reply suggestions make sense
- [ ] Haptic feedback is consistent
### Edge Cases
- [ ] Long translations don't overflow
- [ ] Plurals work (0, 1, many)
- [ ] Date/time formats correct
- [ ] AM/PM vs 24h format
### Battery Impact
- [ ] No excessive text measurement
- [ ] Translations cached appropriately
Best Practices Summary
Do's
- Keep text extremely short - 2-3 words max for labels
- Use icons with text - Icons are universal
- Test on real devices - Emulators don't show font rendering accurately
- Cache aggressively - Battery matters on wearables
- Support voice input - Common interaction method
Don'ts
- Don't translate word-for-word - Rewrite for brevity
- Don't use long sentences - Users glance, not read
- Don't ignore script complexity - Some need larger fonts
- Don't skip RTL testing - Layout issues are common
- Don't overuse haptics - One pattern per meaning
Conclusion
Wearable localization requires a different mindset: every character counts. Focus on:
- Brevity - Ruthlessly short translations
- Clarity - Instant comprehension
- Universality - Icons + text
For managing your wearable app translations efficiently, check out FlutterLocalisation.
Related Articles:
- Flutter TV App Localization
- Flutter Localization Accessibility
- Compare ARB Files - Find missing watch translations