Arabic and RTL in Flutter: a practical guide

Youssef Mohamed20 min read

What Flutter does for Arabic, and what it leaves to you

I build Flutter apps for clients in Egypt and the Gulf, and nearly all of them ship in Arabic and English. Flutter handles right-to-left layout well: once the active locale is Arabic, rows reverse, text aligns right, the back arrow flips and the drawer opens from the right. The catch is that it only does this for code written with direction in mind.

Most RTL bugs I fix in existing apps come from a few habits: padding written with left and right, hand-drawn icons, phone numbers dropped into Arabic sentences, and dates built by string concatenation. None of them show up while you test in English.

This is the setup I use on real projects, from pubspec.yaml to the tests that run before each release. The code is Dart 3, and I checked every sample against the current stable Flutter release.

Set up flutter_localizations and gen-l10n

Flutter's own translations for buttons, date pickers and screen reader labels live in flutter_localizations, which ships with the SDK. Your strings go in ARB files, and gen-l10n turns them into a typed Dart class. Run flutter pub add flutter_localizations --sdk=flutter and flutter pub add intl:any, turn on generation, and add l10n.yaml next to pubspec.yaml.

nullable-getter: false makes AppLocalizations.of(context) non-null, so you skip the exclamation mark on every screen. untranslated-messages-file writes a JSON list of keys missing from the Arabic file each time the code is generated, which is the quickest way to catch a forgotten string before a release.

Older tutorials import package:flutter_gen/gen_l10n/app_localizations.dart. That synthetic package has been removed: the generated files now live next to your ARB files by default, and you import them like any other file in lib.

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  intl: any

flutter:
  generate: true

# l10n.yaml (project root)
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
nullable-getter: false
untranslated-messages-file: untranslated.json

Write the English template ARB file

The template file is the source of truth. It holds every key, a description that tells the translator where the text appears, and the type of each placeholder. Plurals and placeholders use ICU message syntax inside the string, so the logic stays out of your widgets.

Name keys after their meaning, not the English words: itemsInCart still makes sense when the copy changes.

{
  "@@locale": "en",
  "appTitle": "Orders",
  "@appTitle": {
    "description": "App name shown in the task switcher"
  },
  "welcome": "Welcome back, {name}",
  "@welcome": {
    "placeholders": {
      "name": { "type": "String", "example": "Sara" }
    }
  },
  "itemsInCart": "{count, plural, =0{Your cart is empty} =1{1 item in your cart} other{{count} items in your cart}}",
  "@itemsInCart": {
    "placeholders": {
      "count": { "type": "int" }
    }
  }
}

Add the Arabic ARB file

The Arabic file only needs the keys and their translations. Types and descriptions come from the template. Arabic has six plural categories (zero, one, two, few, many and other), so give each one its own wording instead of copying the English two-way split. I go deeper into plurals in a separate article on Arabic plurals with gen-l10n.

Generation runs on flutter run and flutter build. Run flutter gen-l10n yourself when you want new getters to appear in your editor straight away.

{
  "@@locale": "ar",
  "appTitle": "الطلبات",
  "welcome": "أهلًا بعودتك يا {name}",
  "itemsInCart": "{count, plural, =0{السلة فارغة} =1{منتج واحد في السلة} =2{منتجان في السلة} few{{count} منتجات في السلة} many{{count} منتجًا في السلة} other{{count} منتج في السلة}}"
}

Wire MaterialApp and let the direction follow the locale

AppLocalizations.localizationsDelegates already contains your generated delegate plus GlobalMaterialLocalizations, GlobalCupertinoLocalizations and GlobalWidgetsLocalizations. The last one tells Flutter that Arabic is right to left; leave it out of a custom list and you get Arabic words on a left-to-right layout.

Under MaterialApp sits a Localizations widget, and it wraps everything below it in a Directionality widget built from the active locale. Every widget that has a start and an end reads that value with Directionality.of(context). In Arabic, start is the right side and end is the left side.

Watch the order of supportedLocales. When the phone is set to a language you don't support, such as French, Flutter falls back to the first entry, and the generated AppLocalizations.supportedLocales list is sorted alphabetically, so Arabic comes first. Pass your own list with English at the top if English should be the fallback. Matching is by language code, so a phone set to Arabic (Egypt) or Arabic (Saudi Arabia) resolves to Locale('ar').

import 'package:flutter/material.dart';

import 'l10n/app_localizations.dart';

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      // The first entry is the fallback for languages you don't support.
      supportedLocales: const [Locale('en'), Locale('ar')],
      home: const HomeScreen(),
    );
  }
}

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context);
    return Scaffold(
      appBar: AppBar(title: Text(l10n.appTitle)),
      body: Center(child: Text(l10n.itemsInCart(3))),
    );
  }
}

Switch the language at runtime and remember it

Most apps need a language setting inside the app, not only the phone's system setting. The simplest version that fits any state management is a ChangeNotifier that holds the chosen locale, saves it with shared_preferences and rebuilds MaterialApp when it changes. Bloc, Riverpod or Provider can hold the same value.

A null locale means follow the phone, a good default for the first launch. Load the saved choice before runApp, so the first frame already has the right language and direction. Changing the locale keeps the navigation stack and widget state, so the user stays on the settings screen while the app flips around them.

Write each language name in its own language, English and العربية, so someone who lands in the wrong language can still find the way back.

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'l10n/app_localizations.dart';

class LocaleController extends ChangeNotifier {
  LocaleController._(this._prefs, this._locale);

  static const _key = 'app_locale';
  final SharedPreferencesAsync _prefs;
  Locale? _locale;

  /// null means: follow the phone's language.
  Locale? get locale => _locale;

  static Future<LocaleController> load() async {
    final prefs = SharedPreferencesAsync();
    final code = await prefs.getString(_key);
    return LocaleController._(prefs, code == null ? null : Locale(code));
  }

  Future<void> setLocale(Locale? locale) async {
    if (locale == _locale) return;
    _locale = locale;
    notifyListeners();
    if (locale == null) {
      await _prefs.remove(_key);
    } else {
      await _prefs.setString(_key, locale.languageCode);
    }
  }
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final localeController = await LocaleController.load();
  runApp(App(localeController: localeController));
}

class App extends StatelessWidget {
  const App({super.key, required this.localeController});

  final LocaleController localeController;

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: localeController,
      builder: (context, _) => MaterialApp(
        locale: localeController.locale,
        localizationsDelegates: AppLocalizations.localizationsDelegates,
        supportedLocales: const [Locale('en'), Locale('ar')],
        home: HomeScreen(localeController: localeController),
      ),
    );
  }
}

class LanguageButton extends StatelessWidget {
  const LanguageButton({super.key, required this.controller});

  final LocaleController controller;

  @override
  Widget build(BuildContext context) {
    final isArabic = Localizations.localeOf(context).languageCode == 'ar';
    return TextButton(
      onPressed: () => controller.setLocale(Locale(isArabic ? 'en' : 'ar')),
      // Each language name is written in its own language.
      child: Text(isArabic ? 'English' : 'العربية'),
    );
  }
}

Use start and end instead of left and right

Anything written with left or right stays where it is when the app turns Arabic, and it is the most common RTL bug I find when I review a Flutter codebase. Each fixed API has a directional twin that resolves against Directionality:

  • EdgeInsets.only(left: 16) becomes EdgeInsetsDirectional.only(start: 16), and EdgeInsets.fromLTRB becomes EdgeInsetsDirectional.fromSTEB
  • Alignment.centerLeft becomes AlignmentDirectional.centerStart, including in gradients and in Stack alignment
  • Positioned(left: 0) becomes PositionedDirectional(start: 0)
  • BorderRadius.only(topLeft: ...) becomes BorderRadiusDirectional.only(topStart: ...)
  • Border(left: ...) becomes BorderDirectional(start: ...)
  • TextAlign.left becomes TextAlign.start, the default, so you can often just delete it
  • Row, Wrap, ListTile and CrossAxisAlignment.start in a Column already follow the direction, so leave them as they are
class OrderCard extends StatelessWidget {
  const OrderCard({super.key, required this.title, required this.subtitle});

  final String title;
  final String subtitle;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Container(
          margin: const EdgeInsetsDirectional.only(start: 16, end: 8, top: 8),
          padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 8, 12),
          decoration: const BoxDecoration(
            borderRadius: BorderRadiusDirectional.only(
              topStart: Radius.circular(16),
              bottomStart: Radius.circular(16),
            ),
            gradient: LinearGradient(
              begin: AlignmentDirectional.centerStart,
              end: AlignmentDirectional.centerEnd,
              colors: [Color(0xFFE3F2FD), Color(0xFFF5F5F5)],
            ),
          ),
          child: Row(
            children: [
              Expanded(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(title, textAlign: TextAlign.start),
                    const SizedBox(height: 4),
                    Text(subtitle, textAlign: TextAlign.start),
                  ],
                ),
              ),
              const Icon(Icons.chevron_right), // mirrors on its own
            ],
          ),
        ),
        const PositionedDirectional(
          top: 0,
          end: 0,
          child: CircleAvatar(radius: 6, backgroundColor: Color(0xFFE53935)),
        ),
      ],
    );
  }
}

What mirrors on its own, and icons that must flip

Row, horizontal ListView, PageView and TabBar reverse their order in Arabic. The drawer opens from the right, and Slider, Dismissible and the back button follow the direction too. Material icons that point somewhere, such as arrow_back, arrow_forward, chevron_left, chevron_right, send, reply, undo and redo, are defined with matchTextDirection: true and flip on their own.

Images only flip if you ask: set matchTextDirection: true on Image.asset or Image.network for an illustrated arrow or a swipe hint. Icons from your own icon font need matchTextDirection: true on the IconData. For anything else, such as an animation or a shape built from widgets, wrap it in Transform.flip and decide with Directionality.of(context).

Some things must not flip: a play button, a clock, a logo, a checkmark. A text arrow such as → inside a string keeps pointing right too, because Unicode does not mirror it, so use an icon for any arrow that means forward or back.

// An icon from your own icon font that should mirror.
const IconData appForwardIcon = IconData(
  0xe901,
  fontFamily: 'AppIcons',
  matchTextDirection: true,
);

/// Flips any child horizontally when the layout is right to left.
class MirrorInRtl extends StatelessWidget {
  const MirrorInRtl({super.key, required this.child});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    final isRtl = Directionality.of(context) == TextDirection.rtl;
    return Transform.flip(flipX: isRtl, child: child);
  }
}

class NextStepHint extends StatelessWidget {
  const NextStepHint({super.key});

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        const Icon(Icons.arrow_forward), // Material: mirrors on its own
        const Icon(appForwardIcon), // mirrors because of matchTextDirection
        Image.asset('assets/images/swipe_hand.png', matchTextDirection: true),
        const MirrorInRtl(child: SwipeAnimation()), // your own widget
        const Icon(Icons.play_arrow), // media icon: does not mirror
      ],
    );
  }
}

Mixed Arabic and English text

Inside one Text widget, Flutter applies the Unicode bidirectional algorithm. It handles an English word inside an Arabic sentence well, but runs that mix digits, spaces and symbols confuse it. A phone number like +20 100 000 0000 inside Arabic text often comes out with its digit groups reversed and the plus sign on the wrong side.

The fix is Unicode isolates. U+2066 starts a left-to-right isolate, U+2067 a right-to-left one, U+2068 one that takes its direction from its first strong letter, and U+2069 closes any of them. Wrap the value before you pass it into a translated string, so the ARB files stay clean.

For a value on its own line, such as an email, a coupon code or an IBAN, set textDirection: TextDirection.ltr on the Text, or wrap a larger block in its own Directionality. For text written by users, such as chat messages or reviews, Bidi.startsWithRtl from intl checks the first strong letter, which matches dir=auto on the web.

Import intl with a prefix. It has its own TextDirection class, and an unprefixed import of package:intl/intl.dart breaks every TextDirection.ltr in that file with confusing errors.

import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;

/// Keeps a left-to-right value (phone number, order code, English name)
/// in one piece inside right-to-left text.
String isolateLtr(String text) => '\u2066$text\u2069';

/// The same for an Arabic value inside English text.
String isolateRtl(String text) => '\u2067$text\u2069';

class ContactLine extends StatelessWidget {
  const ContactLine({super.key, required this.phone, required this.note});

  final String phone; // '+20 100 000 0000'
  final String note; // typed by a customer, any language

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // Without the isolate, the digit groups come out in reverse order.
        Text('اتصل بنا على ${isolateLtr(phone)}'),
        // A value that is always left to right.
        const Text('[email protected]', textDirection: TextDirection.ltr),
        // Follow the first strong letter, like dir="auto" on the web.
        Text(
          note,
          textDirection: intl.Bidi.startsWithRtl(note)
              ? TextDirection.rtl
              : TextDirection.ltr,
        ),
      ],
    );
  }
}

Numbers, phone numbers, dates and currency

Use intl for anything a user reads as a number, a date or an amount, and pass the active locale from Localizations.localeOf(context). A date assembled by hand gets the order and the month names wrong in Arabic.

Decide on digits early. Some clients want Eastern Arabic digits (٠١٢٣), others Western digits (0123); it is a product decision. Inside a Flutter app, intl disagrees with itself here: DateFormat for ar uses the date data that ships with flutter_localizations and prints Eastern digits, while NumberFormat for ar prints Western digits. Call DateFormat.useNativeDigitsByDefaultFor('ar', false) at startup for Western digits everywhere, or format numbers with ar_EG for Eastern digits everywhere.

For money, use NumberFormat.currency with both the currency code and the symbol. NumberFormat.simpleCurrency with an Arabic locale and no currency name falls back to the Egyptian pound sign, which is wrong for a Saudi or Emirati store. The result also starts with an invisible right-to-left mark (U+200F): keep it for display, and never compare formatted strings in logic.

Store phone numbers in international format and display them in a left-to-right isolate, as in the previous section.

import 'package:flutter/material.dart';
import 'package:intl/intl.dart' as intl;

void main() {
  // Flutter's Arabic date data prints ٠١٢٣, NumberFormat('ar') prints 0123.
  // Pick one style for the whole app. Here: Western digits everywhere.
  intl.DateFormat.useNativeDigitsByDefaultFor('ar', false);
  runApp(const App());
}

class OrderSummary extends StatelessWidget {
  const OrderSummary({super.key, required this.total, required this.placedAt});

  final double total;
  final DateTime placedAt;

  @override
  Widget build(BuildContext context) {
    final locale = Localizations.localeOf(context).toString(); // 'ar' or 'en'
    final isArabic = locale.startsWith('ar');

    final date = intl.DateFormat.yMMMMd(locale).format(placedAt);
    final time = intl.DateFormat.jm(locale).format(placedAt);
    final amount = intl.NumberFormat.currency(
      locale: locale,
      name: 'SAR',
      symbol: isArabic ? 'ر.س' : 'SAR ',
      decimalDigits: 2,
    ).format(total);

    // ar: 20 أكتوبر 2026 · 2:05 م · 1,250.50 ر.س
    // en: October 20, 2026 · 2:05 PM · SAR 1,250.50
    return Text('$date · $time · $amount');
  }
}

Arabic fonts, line height and letterSpacing

Without a bundled font, Flutter uses the platform's Arabic font, which differs between Android and iOS. For a consistent look, bundle an Arabic family licensed for app use and declare it under flutter: fonts: in pubspec.yaml. If it has no Latin letters, keep a Latin family as fontFamily and list the Arabic one in fontFamilyFallback, or switch families by locale as below.

Arabic letters carry dots and marks above and below the line, so the same font size needs more vertical room. Avoid fixed heights on boxes that hold text, give Arabic a little more line height, and test with diacritics such as تَجْرِبَة if your content uses them. Flutter already switches Material's text geometry to its tall set for Arabic, but your font decides how much room is really needed.

Never add letterSpacing to Arabic: the letters join, and tracking opens gaps between them. Material 3's default body and label styles carry a small positive letterSpacing that applies to Arabic too, so I reset it to zero in the Arabic theme. Arabic has no capital letters either, so a design that relies on uppercase needs another kind of emphasis, usually weight or size.

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: const [Locale('en'), Locale('ar')],
      theme: ThemeData(fontFamily: 'Inter'),
      builder: (context, child) {
        final theme = Theme.of(context);
        final isArabic = Localizations.localeOf(context).languageCode == 'ar';
        return Theme(
          data: isArabic
              ? theme.copyWith(
                  textTheme: theme.textTheme.apply(
                    fontFamily: 'NotoSansArabic',
                    letterSpacingFactor: 0, // no tracking between joined letters
                    heightFactor: 1.1, // room for dots and diacritics
                  ),
                )
              : theme,
          child: child!,
        );
      },
      home: const HomeScreen(),
    );
  }
}

Text fields in an Arabic interface

A TextField takes its direction from the surrounding Directionality, so in Arabic the cursor starts on the right. That suits names, addresses and messages, and mixed input such as an Arabic street name with an English building name displays correctly.

Emails, URLs, passwords, phone numbers and codes read left to right, so set textDirection: TextDirection.ltr on those fields and hintTextDirection on the InputDecoration to match. Otherwise the hint keeps the screen's direction and sits on the opposite side from the typed text. The label and the prefix and suffix icons keep following the screen, which is correct.

Pick the right keyboardType, such as TextInputType.emailAddress, TextInputType.phone or TextInputType.numberWithOptions(decimal: true). Depending on the phone and keyboard, people using Arabic may type Eastern Arabic digits. FilteringTextInputFormatter.digitsOnly only allows 0 to 9, so those digits vanish silently, and int.tryParse returns null for them. Convert them before any filter or parser sees the text.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

/// Turns Arabic-Indic (٠-٩) and Persian (۰-۹) digits into 0-9.
String toWesternDigits(String input) {
  final buffer = StringBuffer();
  for (final rune in input.runes) {
    if (rune >= 0x0660 && rune <= 0x0669) {
      buffer.writeCharCode(rune - 0x0660 + 0x30);
    } else if (rune >= 0x06F0 && rune <= 0x06F9) {
      buffer.writeCharCode(rune - 0x06F0 + 0x30);
    } else {
      buffer.writeCharCode(rune);
    }
  }
  return buffer.toString();
}

class WesternDigitsFormatter extends TextInputFormatter {
  const WesternDigitsFormatter();

  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    // Same length as before, so the cursor position stays valid.
    return newValue.copyWith(text: toWesternDigits(newValue.text));
  }
}

class PhoneField extends StatelessWidget {
  const PhoneField({super.key, required this.label});

  final String label;

  @override
  Widget build(BuildContext context) {
    return TextField(
      keyboardType: TextInputType.phone,
      textDirection: TextDirection.ltr,
      inputFormatters: [
        const WesternDigitsFormatter(),
        FilteringTextInputFormatter.allow(RegExp(r'[0-9+ ]')),
      ],
      decoration: InputDecoration(
        labelText: label,
        hintText: '+20 100 000 0000',
        hintTextDirection: TextDirection.ltr,
      ),
    );
  }
}

Page transitions and swipe gestures

Flutter's built-in routes already handle direction. With the Cupertino transition, new pages slide in from the left in Arabic and the back swipe starts from the right edge. PageView, TabBarView and horizontal lists reverse on their own, so don't add reverse: true for Arabic, or you flip them twice.

Your own animations and gestures need the same care. An Offset is in screen terms, so Offset(1, 0) means from the right in every language; SlideTransition takes a textDirection and flips x for you. In a GestureDetector, a positive horizontal velocity is always a drag to the right, so work out what next means from Directionality first.

Dismissible's startToEnd and endToStart follow the reading direction, so the action behind a left swipe in English sits behind a right swipe in Arabic. That is usually right; just check that the background colours and icons match each side.

/// A page that slides in from the end side: the right in English,
/// the left in Arabic.
Route<T> slideFromEnd<T>(Widget page) {
  return PageRouteBuilder<T>(
    pageBuilder: (context, animation, secondaryAnimation) => page,
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      final position = Tween<Offset>(
        begin: const Offset(1, 0),
        end: Offset.zero,
      ).chain(CurveTween(curve: Curves.easeOutCubic)).animate(animation);
      return SlideTransition(
        position: position,
        textDirection: Directionality.of(context), // flips x in RTL
        child: child,
      );
    },
  );
}

/// Swipe toward the start side for "next", whatever the language.
class SwipeNavigator extends StatelessWidget {
  const SwipeNavigator({
    super.key,
    required this.onNext,
    required this.onPrevious,
    required this.child,
  });

  final VoidCallback onNext;
  final VoidCallback onPrevious;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    final isRtl = Directionality.of(context) == TextDirection.rtl;
    return GestureDetector(
      onHorizontalDragEnd: (details) {
        final velocity = details.primaryVelocity ?? 0;
        if (velocity.abs() < 300) return;
        // Positive velocity is a drag to the right on screen.
        final towardStart = isRtl ? velocity > 0 : velocity < 0;
        towardStart ? onNext() : onPrevious();
      },
      child: child,
    );
  }
}

CustomPainter and the canvas

The canvas knows nothing about text direction. Point (0, 0) is the top-left corner in every language, so a progress bar, chart or step indicator that you paint yourself looks the same in Arabic unless you handle it. Pass the direction into the painter from Directionality.of(context), use it when you compute x positions, and include it in shouldRepaint so a language change repaints.

For a drawing with many shapes, mirroring the whole canvas is shorter: canvas.save(), canvas.translate(size.width, 0) and canvas.scale(-1, 1), paint the shapes, then canvas.restore(). Draw text after the restore, never inside the mirrored part, or it comes out as a mirror image. TextPainter needs a textDirection anyway, so give it the same value you passed to the painter.

class ProgressBarPainter extends CustomPainter {
  ProgressBarPainter({
    required this.progress,
    required this.color,
    required this.textDirection,
  });

  final double progress;
  final Color color;
  final TextDirection textDirection;

  @override
  void paint(Canvas canvas, Size size) {
    final radius = Radius.circular(size.height / 2);
    final track = Paint()..color = color.withValues(alpha: 0.2);
    final fill = Paint()..color = color;

    canvas.drawRRect(RRect.fromRectAndRadius(Offset.zero & size, radius), track);

    // The canvas has no direction: x = 0 is always the left edge.
    final width = size.width * progress.clamp(0.0, 1.0);
    final left = textDirection == TextDirection.rtl ? size.width - width : 0.0;
    canvas.drawRRect(
      RRect.fromRectAndRadius(Rect.fromLTWH(left, 0, width, size.height), radius),
      fill,
    );
  }

  @override
  bool shouldRepaint(ProgressBarPainter oldDelegate) =>
      oldDelegate.progress != progress ||
      oldDelegate.color != color ||
      oldDelegate.textDirection != textDirection;
}

class UploadProgress extends StatelessWidget {
  const UploadProgress({super.key, required this.progress});

  final double progress;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 8,
      width: double.infinity,
      child: CustomPaint(
        painter: ProgressBarPainter(
          progress: progress,
          color: Theme.of(context).colorScheme.primary,
          textDirection: Directionality.of(context),
        ),
      ),
    );
  }
}

Test both directions

I run the important screens in both languages in widget tests. ValueVariant runs the same test once per locale, and pumping the real localization setup with the locale ar gives the same Directionality the app uses. Overflow errors fail the test on their own, which catches Arabic text that is longer or taller than the English.

Golden tests turn layout into a picture you can review. Create the reference images with flutter test --update-goldens, check them, and commit them. By default the test environment draws letters and icons as plain boxes, so goldens show layout and direction rather than real glyphs; load your font with FontLoader if you need the real letters.

To simulate a phone set to Arabic, set tester.platformDispatcher.localesTestValue and clear it with addTearDown(tester.platformDispatcher.clearLocalesTestValue). Tests don't replace devices, though: before each release I check one Android phone and one iPhone switched to Arabic.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/l10n/app_localizations.dart';
import 'package:my_app/widgets/order_card.dart';

Widget localized(Widget child, Locale locale) {
  return MaterialApp(
    locale: locale,
    localizationsDelegates: AppLocalizations.localizationsDelegates,
    supportedLocales: AppLocalizations.supportedLocales,
    home: Scaffold(body: child),
  );
}

void main() {
  final locales = ValueVariant<Locale>({const Locale('en'), const Locale('ar')});

  testWidgets('OrderCard in both directions', (tester) async {
    final locale = locales.currentValue!;
    await tester.pumpWidget(
      localized(const OrderCard(title: 'Order 1042', subtitle: 'On the way'), locale),
    );
    await tester.pumpAndSettle();

    final title = tester.getCenter(find.text('Order 1042'));
    final chevron = tester.getCenter(find.byIcon(Icons.chevron_right));
    if (locale.languageCode == 'ar') {
      expect(chevron.dx, lessThan(title.dx)); // the end side is the left
    } else {
      expect(chevron.dx, greaterThan(title.dx));
    }

    await expectLater(
      find.byType(OrderCard),
      matchesGoldenFile('goldens/order_card_${locale.languageCode}.png'),
    );
  }, variant: locales);
}

Release checklist and what to do next

  • GlobalWidgetsLocalizations is in the delegates, and the fallback language comes first in supportedLocales
  • No left or right padding, alignment, positioning, corner radius or text alignment remains, unless the side is truly fixed
  • Arrows and progress bars point the right way in Arabic; play buttons, logos and clocks do not flip
  • Phone numbers, emails, codes and prices read correctly inside Arabic sentences
  • Dates, numbers and currency go through intl, with one digit style on every screen
  • No letterSpacing on Arabic text, and no clipped dots or diacritics in buttons, chips and app bars
  • Email, password and phone fields are left to right with matching hints, and Eastern Arabic digits are accepted
  • Custom transitions, swipe gestures and painted widgets were checked in Arabic
  • The language choice survives a restart, and switching it keeps the user on the same screen
  • Widget tests and goldens pass in both locales, and untranslated.json is empty
  • A real Android phone and a real iPhone set to Arabic were checked by hand

On a new app, set up gen-l10n and directional widgets on day one, because adding RTL later means touching every screen. For an existing app that looks wrong in Arabic, start with the delegates, search the code for left, right, centerLeft and TextAlign.left, then work through this list screen by screen.

If you want help building a bilingual Flutter app, or fixing the Arabic version of one you already have, you can reach me through this site. I work with clients in Egypt, Saudi Arabia, the UAE and Europe.

Common questions

How do I make a Flutter app right to left?
Add flutter_localizations, list Arabic in supportedLocales and include GlobalWidgetsLocalizations in the localization delegates. When the active locale is Arabic, Flutter wraps the app in a right-to-left Directionality and mirrors rows, lists, Material icons and navigation. Your own code then needs start and end instead of left and right to follow along.
Why is my Flutter app not switching to RTL in Arabic?
Usually GlobalWidgetsLocalizations.delegate is missing, or Arabic is not in supportedLocales, so Flutter falls back to another language. Print Localizations.localeOf(context) and Directionality.of(context) to see what the app really uses. If both are right but parts of the screen stay left to right, those widgets use fixed left and right values.
How do I change the language of a Flutter app without restarting it?
Keep the chosen Locale in a ChangeNotifier, a ValueNotifier or your state management of choice, and pass it to the locale property of MaterialApp inside a builder that listens to it. Changing the value rebuilds the app in the new language and direction straight away, and the user stays on the same screen. Save the choice with shared_preferences and load it before runApp.
How do I show Arabic numerals like ٠١٢٣ in Flutter?
Format numbers with intl's NumberFormat and a locale that uses them, such as ar_EG. In a Flutter app, DateFormat for ar already prints Eastern Arabic digits while NumberFormat for ar prints Western digits, so choose one style and apply it everywhere. DateFormat.useNativeDigitsByDefaultFor switches dates to Western digits if that is the style you picked.
Why does a phone number appear reversed in Arabic text?
Inside right-to-left text, the Unicode bidirectional algorithm treats each group of digits as a separate run, so the groups can come out in reverse order. Wrap the number in a left-to-right isolate, with U+2066 before it and U+2069 after it, or show it in its own Text with textDirection set to ltr.
Should I use Directionality or the locale to make my Flutter app RTL?
Use the locale for the app as a whole, because it sets the direction, the translations and the date and number formats together. Use a Directionality widget only to force one part of the screen, such as an email field or a code snippet, into a specific direction. A right-to-left Directionality on top of an English locale gives you a mirrored layout with English labels and formats.
Which font should I use for Arabic in Flutter?
Any well made Arabic family with the weights you need and a licence that allows use in an app. Bundle it so the app looks the same on Android and iOS, pair it with a Latin font if it has no Latin letters, and test it at small sizes with long words and diacritics.

Share

Available for new projects

Profiles