Localize 'Minutes Ago' in Flutter with ARB Plurals
Every feed, chat, and notification list needs a "5 minutes ago" label, and most Flutter codebases get there one of two ways: a hand-rolled '${diff.inMinutes} minutes ago' string, or the timeago package. Both ship English grammar to your Polish, Russian, and Arabic users — and neither lives in your ARB files, where the rest of your translations do.
Here's the reframe that fixes it: relative time is not a date-format problem, it's a plural problem. DateFormat from intl formats absolute dates and has no relative-time formatter at all — searching for a DateFormat relative time API in Flutter is a dead end. But Flutter's gen-l10n toolchain already handles ICU plurals with full CLDR rules. A {count, plural, ...} message per time unit plus a 20-line DateTime diff helper gives you relative timestamps that are fully localized, type-safe, and editable by translators.
Why timeago and hard-coded strings fall short
Most flutter timeago localization guides tell you to register locale messages manually:
import 'package:timeago/timeago.dart' as timeago;
timeago.setLocaleMessages('pl', timeago.PlMessages());
That works, but it has real costs:
- Only
enandesload by default (as of timeago 3.7.1). Forget to register a locale and the package silently falls back to English, loggingLocale [ru] has not been added, using [en] as fallbackto the console — your Russian users just see English. - Messages are Dart classes, not translation files. Each locale implements methods like
minutes(int minutes)returning fixed strings, so your translators can't touch them, and they live outside the ARB workflow the rest of your app uses. - It's a second source of truth. Your app strings go through ARB review and translation management; your timestamps don't.
Hand-rolled strings have all the same problems, minus the package.
Relative time is really a plural problem
English only distinguishes "1 minute ago" from "n minutes ago", which is why hard-coded strings look fine in development. Other languages need more forms, defined by the CLDR plural categories (zero, one, two, few, many, other):
| Count | English | Polish | Russian |
|---|---|---|---|
| 1 | 1 minute ago | 1 minutę temu | 1 минуту назад |
| 2 | 2 minutes ago | 2 minuty temu (few) |
2 минуты назад (few) |
| 5 | 5 minutes ago | 5 minut temu (many) |
5 минут назад (many) |
| 21 | 21 minutes ago | 21 minut temu | 21 минуту назад (one!) |
Russian even routes 21 back to the one category. Arabic uses all six categories, with distinct forms for two items (دقيقتين) and for 3–10 versus 11–99. No if (count == 1) check survives contact with this — but ICU plural messages handle it natively, because intl applies the correct CLDR rule for the active locale automatically.
The ARB setup
Standard gen-l10n wiring — pubspec.yaml needs flutter_localizations and intl, plus an l10n.yaml:
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
Then define one ARB plural message per time unit in lib/l10n/app_en.arb:
{
"@@locale": "en",
"justNow": "just now",
"minutesAgo": "{count, plural, one{{count} minute ago} other{{count} minutes ago}}",
"@minutesAgo": {
"description": "Relative timestamp shown in feeds, e.g. '5 minutes ago'",
"placeholders": {"count": {"type": "int"}}
},
"hoursAgo": "{count, plural, one{{count} hour ago} other{{count} hours ago}}",
"@hoursAgo": {"placeholders": {"count": {"type": "int"}}},
"daysAgo": "{count, plural, one{{count} day ago} other{{count} days ago}}",
"@daysAgo": {"placeholders": {"count": {"type": "int"}}}
}
The other branch is mandatory in ICU syntax — gen-l10n errors without it. Every other category is optional, which is exactly where the trouble starts (more on that below).
The DateTime-diff helper
This is the entire replacement for timeago — a pure function that picks a unit and delegates the grammar to your ARB messages:
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';
import 'l10n/app_localizations.dart';
String relativeTime(BuildContext context, DateTime timestamp, {DateTime? clock}) {
final l10n = AppLocalizations.of(context)!;
final diff = (clock ?? DateTime.now()).difference(timestamp);
if (diff.inSeconds < 60) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
if (diff.inDays < 7) return l10n.daysAgo(diff.inDays);
// Older than a week: an absolute date reads better than "43 days ago".
return DateFormat.yMMMd(l10n.localeName).format(timestamp);
}
Use it anywhere: Text(relativeTime(context, message.sentAt)). The clock parameter makes it trivially testable, and the DateFormat.yMMMd fallback keeps old items readable — the one place absolute date formatting genuinely belongs. If timestamps must tick while visible, rebuild on a Timer.periodic — that's the only thing timeago was doing for you that this doesn't.
Switch the device to Polish or Arabic and every timestamp follows, with correct grammar — as long as your translated ARBs carry the right plural categories.
How it silently breaks in Polish, Russian, and Arabic
Here's the failure mode that makes this worth a whole post. A translator (or a copy-paste from app_en.arb) produces this app_pl.arb:
{
"@@locale": "pl",
"minutesAgo": "{count, plural, one{{count} minutę temu} other{{count} minut temu}}"
}
This compiles cleanly and never throws. ICU's rule for a missing category is to fall back to other, so Polish counts of 2–4 — which need the few form minuty — render as "2 minut temu". That's a visible grammar error to every Polish user, on every screen with a timestamp. The same shape breaks Russian (few/many collapse into one wrong form) and Arabic (dual and 3–10 forms disappear).
Worse, the standard safety net doesn't catch it. gen-l10n's untranslated-messages-file report only lists messages missing entirely from a locale — a plural message that exists but lacks few and many looks fully translated to the tooling. The bug ships silently.
The correct Polish and Arabic ARBs
Translated files only need the values (the @-metadata lives in the template). Copy-paste app_pl.arb:
{
"@@locale": "pl",
"justNow": "przed chwilą",
"minutesAgo": "{count, plural, one{{count} minutę temu} few{{count} minuty temu} many{{count} minut temu} other{{count} minuty temu}}",
"hoursAgo": "{count, plural, one{{count} godzinę temu} few{{count} godziny temu} many{{count} godzin temu} other{{count} godziny temu}}",
"daysAgo": "{count, plural, one{{count} dzień temu} few{{count} dni temu} many{{count} dni temu} other{{count} dnia temu}}"
}
And app_ar.arb, using all the categories Arabic needs (forms follow CLDR's relative-time data):
{
"@@locale": "ar",
"justNow": "الآن",
"minutesAgo": "{count, plural, one{قبل دقيقة واحدة} two{قبل دقيقتين} few{قبل {count} دقائق} many{قبل {count} دقيقة} other{قبل {count} دقيقة}}",
"hoursAgo": "{count, plural, one{قبل ساعة واحدة} two{قبل ساعتين} few{قبل {count} ساعات} many{قبل {count} ساعة} other{قبل {count} ساعة}}",
"daysAgo": "{count, plural, one{قبل يوم واحد} two{قبل يومين} few{قبل {count} أيام} many{قبل {count} يومًا} other{قبل {count} يوم}}"
}
Note how Arabic one and two don't even include the number — "قبل دقيقتين" (two minutes ago) encodes the count grammatically. That's something no '$count minutes ago' template can ever express.
Catch missing plural categories before your users do
The uncomfortable takeaway: the compiler validates your ICU syntax, but nothing in the stock toolchain validates plural completeness per language. A Polish file without few, a Russian file without many, an Arabic file without two — all build green.
This is exactly the gap FlutterLocalisation's ICU plural-syntax validation covers: it flags locales that are missing a plural category their language actually requires, so a dropped few in app_pl.arb surfaces as a warning instead of a shipped grammar bug. Its ARB editor also lets you and your translators edit these plural branches in a UI instead of hand-balancing braces in raw JSON — which is where most broken plural messages come from in the first place.
For the other half of the timestamp story (absolute dates, DateFormat patterns per locale), see our guide to Flutter DateTime localization.
Relative timestamps touch every screen of a feed-driven app — it's worth getting the plurals right once, in ARB, instead of patching a string package per locale. There's a free tier to start with: Try FlutterLocalisation free and run your existing ARB files through the plural validation.