← Back to Blog

Flutter Wearable App Localization: Wear OS and watchOS Guide

flutterwearablewear-oswatchossmartwatchlocalizationvoice

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

  1. Keep text extremely short - 2-3 words max for labels
  2. Use icons with text - Icons are universal
  3. Test on real devices - Emulators don't show font rendering accurately
  4. Cache aggressively - Battery matters on wearables
  5. Support voice input - Common interaction method

Don'ts

  1. Don't translate word-for-word - Rewrite for brevity
  2. Don't use long sentences - Users glance, not read
  3. Don't ignore script complexity - Some need larger fonts
  4. Don't skip RTL testing - Layout issues are common
  5. 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: