Precise, dependency-free conversion between the Ethiopian (Ge'ez) calendar and the Gregorian calendar for Dart — dates and times, down to the microsecond, with a familiar date-manipulation API and built-in localization.
final newYear = EthiopianDateTime(2017, 1, 1);
print(newYear.toDateTime()); // 2024-09-11 00:00:00.000
print(newYear.format('MMMM d, YYYY')); // Meskerem 3, 2017
print(newYear.fromNow()); // "in 3 months" (relative to today)
print(newYear.format('MMMM', 'am')); // መስከረም- Why this package
- Installation
- Quick start
- Core concepts
- API reference
EthiopianDateTime— constructingEthiopianDateTime— reading fieldsEthiopianDateTime— converting back toDateTimeEthiopianDateTime— formatting & parsingEthiopianDateTime— arithmeticEthiopianDateTime— calendar navigation (startOf/endOf)EthiopianDateTime— comparing and queryingEthiopianDateTime— relative & contextual timeEthiopianDateTime— equality, ordering, miscEthiopianDateTime— static utilitiesEthiopianDateTime— Ge'ez (Ethiopic) numerals- Top-level calendar math functions
- Top-level Ge'ez numeral functions
- Top-level formatting functions
- Top-level relative-time function
- The locale system
- Format token reference
- Recipes
- Reference for AI agents / LLM-assisted development
- Correctness & testing
- Performance
- Known limitations
- Contributing
- Authors
- License
Most tooling in this space falls into one of two categories: date-only converters that drop time-of-day entirely (fine for a calendar picker, useless for a timestamp), or rewritten API surfaces that force you to relearn a whole new mental model just to work with a different calendar system.
This package takes a different approach:
- Time-of-day is not an afterthought. Every conversion carries full microsecond precision through, because the underlying value of truth is a real
DateTime, not a reconstructed date. Midnight rollovers, leap years, and the 13th month (Pagume) are handled by the same code path as every other day — there's no special-cased "date-only" mode that silently loses your timestamp. - The calendar and the clock are kept strictly separate — on purpose. Ethiopia's traditional way of naming hours (the day is said to "start" at dawn, so 7 AM civil time is spoken of as "1 o'clock") has nothing to do with the calendar date, but it's an easy thing to get wrong: bake that convention into your date conversion logic and you'll silently corrupt every timestamp that falls between midnight and dawn. This package exposes the traditional 12-hour dawn-based display as an explicit, opt-in formatting method that can never affect the underlying date — so the bug class doesn't exist here, rather than being something you have to remember to avoid.
- The math is verified, not "should work." Every date in a multi-century range round-trips exactly, and the epoch calibration is checked against independently-reported real-world reference dates (Ethiopian New Year in multiple recent years) rather than trusted from a single source. You can read exactly how in Correctness & testing — nothing here is asserted without a way to check it yourself.
- The API is one you already half-know. If you've ever formatted a date with a
YYYY-MM-DD-style token string, addednmonths to a date and expected month-end values to clamp sensibly, or called something like.fromNow()to get a humanized timestamp, this package's method names and behavior will feel immediately familiar — because it deliberately mirrors the most widely recognized JavaScript date-manipulation conventions, adapted to Dart idioms (immutable objects, typed enums instead of magic strings,nullinstead of a sentinel "invalid" object). - Localization is a first-class citizen, not a bolt-on. Every user-facing string — month and weekday names, AM/PM, relative-time phrases ("3 days ago"), and contextual phrases ("Today at 2:30 PM") — goes through a small, explicit locale registry with English, Amharic, and Tigrinya built in. Adding a language your app needs is a matter of filling in one data class and registering it; nothing else in the package needs to change.
- Zero runtime dependencies. The entire library is built on Dart's own
DateTimeand core libraries. Nothing to audit in a dependency tree, nothing to break on a transitive version bump.
Add to your pubspec.yaml:
dependencies:
ethiopian_datetime_converter: ^0.4.0Then:
import 'package:ethiopian_datetime_converter/ethiopian_datetime_converter.dart';That single import gives you everything in this document — the EthiopianDateTime class, the top-level conversion/formatting/relative-time functions, and the locale system.
import 'package:ethiopian_datetime_converter/ethiopian_datetime_converter.dart';
void main() {
// Gregorian → Ethiopian
final today = EthiopianDateTime.now();
print(today); // e.g. 2017-06-12T09:41:03.128000
// Ethiopian → Gregorian
final enkutatash = EthiopianDateTime(2017, 1, 1); // 1 Meskerem 2017
print(enkutatash.toDateTime()); // 2024-09-11 00:00:00.000
// From an existing DateTime you already have
final fromGregorian = EthiopianDateTime.fromDateTime(DateTime(2024, 9, 11));
print('${fromGregorian.monthNameEnglish} ${fromGregorian.day}, ${fromGregorian.year}');
// Meskerem 1, 2017
// Human-friendly formatting
print(enkutatash.format('dddd, MMMM Do YYYY')); // Wednesday, Meskerem 1st 2017
// Arithmetic that respects the 13-month calendar
final oneMonthLater = enkutatash.plus(1, EthiopianTimeUnit.month);
print(oneMonthLater); // 2017-02-01T00:00:00.000000
// Relative & contextual display
print(enkutatash.fromNow()); // "in 3 months" (relative to today, changes daily)
print(enkutatash.calendar()); // "01/09/2024" or "Today at ..." depending on the date
// Localized output — no separate translation step needed
print(enkutatash.format('MMMM D, YYYY', 'am')); // መስከረም 1, 2017
}- 13 months: 12 months of exactly 30 days each, plus Pagume, a short 13th month of 5 days (6 in a leap year).
- Leap rule: a year is a leap year whenever
year % 4 == 3— no Gregorian-style century exception. - The year is 7–8 years behind the Gregorian year (the exact gap depends on the time of year), because the two calendars disagree on the calculated date of a shared historical reference point.
- New Year (Enkutatash, 1 Meskerem) falls on 11 September in most years, and 12 September the year after an Ethiopian leap year — this package computes the correct date for any given year rather than applying a fixed offset, which is where naive "just add 7 years" converters tend to go wrong right around month/day boundaries.
Ethiopia's civil time zone is East Africa Time, UTC+3, with no daylight saving time — about as simple as timezones get. What's culturally distinct is a 12-hour clock convention where the day is said to begin at dawn (roughly 06:00 local time): 07:00 is spoken of as "1 o'clock," noon is "6 o'clock," and 18:00 is "12 o'clock" again.
This is a naming convention for the hour, full stop — it is not a timezone, and it does not move the calendar date. Every field on EthiopianDateTime (year, month, day, hour, minute, ...) reflects the real, unshifted civil date and time — the same instant your DateTime already represents, just labeled with Ethiopian month/day names. The dawn-based display is available only through the explicitly-named toEthiopianClockString(), which never changes year/month/day, by design.
Conversion routes through a Julian Day Number (JDN) — a single integer counting days since a fixed astronomical reference point, the same intermediate representation used by serious calendar-conversion software generally. Both directions (Gregorian ⇄ JDN and Ethiopian ⇄ JDN) are pure integer arithmetic — no floating point, no iteration, no lookup tables — which is what makes every conversion in this package exact and O(1). See Correctness & testing for how this was verified.
Every public function and method in the package, in the order you're most likely to need them.
The primary constructor. Builds an Ethiopian date-time in the local timezone (mirrors DateTime(...)).
final dt = EthiopianDateTime(2017, 1, 1); // 1 Meskerem 2017, midnight
final withTime = EthiopianDateTime(2017, 1, 1, 14, 30); // ...at 14:30Throws ArgumentError if month is outside 1..13, or if day is outside the valid range for that month/year (e.g. EthiopianDateTime(2016, 13, 6) throws, because 2016 is not a leap year and Pagume only has 5 days that year).
try {
EthiopianDateTime(2016, 13, 6);
} on ArgumentError catch (e) {
print(e); // Invalid argument (day): Must be in the range 1..5 for month 13 of year 2016
}Same shape as the default constructor, but the result is a UTC instant (mirrors DateTime.utc(...)). Use this whenever you need timezone-independent, exact arithmetic — see Performance for why local-time construction can be a source of off-by-one-hour surprises around daylight saving boundaries in whatever timezone your code happens to run in, while .utc never has that problem.
final dt = EthiopianDateTime.utc(2017, 1, 1, 12);
print(dt.isUtc); // trueWraps an existing DateTime — the most common entry point when you already have Gregorian data (from a database, an API response, DateTime.now(), etc.) and want its Ethiopian-calendar representation. Time-of-day and UTC/local-ness are preserved unchanged; only the date label changes.
final fromApi = DateTime.parse('2024-09-11T08:00:00Z');
final ethiopian = EthiopianDateTime.fromDateTime(fromApi);
print(ethiopian.year); // 2017
print(ethiopian.month); // 1
print(ethiopian.day); // 1The current moment as an EthiopianDateTime. Pass utc: true to base it on DateTime.now() in UTC rather than the device's local timezone.
final now = EthiopianDateTime.now();
final nowUtc = EthiopianDateTime.now(utc: true);All of these are plain getters — cheap, no computation beyond what was already done at construction time.
| Getter | Type | Description |
|---|---|---|
.year |
int |
Ethiopian year |
.month |
int |
Ethiopian month, 1–13 (13 = Pagume) |
.day |
int |
Day of the Ethiopian month |
.hour |
int |
0–23, civil (not dawn-clock) hour |
.minute |
int |
0–59 |
.second |
int |
0–59 |
.millisecond |
int |
0–999 |
.microsecond |
int |
0–999 — the sub-millisecond component only (same convention as DateTime.microsecond; combine with .millisecond for the full fractional second) |
.weekday |
int |
1 (Monday) – 7 (Sunday), same numbering as DateTime.weekday |
.isUtc |
bool |
Whether this instant is UTC |
.isLeapYear |
bool |
Whether .year is an Ethiopian leap year |
.daysInMonth |
int |
30 for months 1–12; 5 or 6 for Pagume depending on .isLeapYear |
.monthNameEnglish |
String |
Latin-transliterated month name, e.g. 'Meskerem' |
.monthNameAmharic |
String |
Amharic month name, e.g. 'መስከረም' |
.weekdayNameEnglish |
String |
Latin-transliterated weekday name, e.g. 'Segno' |
.weekdayNameAmharic |
String |
Amharic weekday name, e.g. 'ሰኞ' |
.microsecondsSinceEpoch |
int |
Microseconds since the Unix epoch — identical to toDateTime().microsecondsSinceEpoch; useful for storage or cross-system interop |
final dt = EthiopianDateTime(2017, 1, 1, 14, 30, 15);
print('${dt.monthNameEnglish} ${dt.day}, ${dt.year} — ${dt.weekdayNameEnglish}');
// Meskerem 1, 2017 — Wednesday
print(dt.isLeapYear); // falseNote:
.monthNameAmharic/.weekdayNameAmharic/etc. are simple, fixed-language convenience getters. For anything beyond English/Amharic, or to select the language dynamically, useformat()with an explicitlocaleargument instead — see The locale system.
Returns the wrapped Gregorian DateTime, unchanged. This is your bridge back into the rest of the Dart/Flutter ecosystem — anything that takes a DateTime (a database driver, intl's DateFormat, a TimePicker, JSON serialization) works with this value directly.
final dt = EthiopianDateTime(2017, 1, 1);
final gregorian = dt.toDateTime();
print(gregorian.toIso8601String()); // 2024-09-11T00:00:00.000Formats the date using a token pattern. With no pattern, identical to .toString(). See Format token reference for the full token table, and The locale system for the locale argument.
final dt = EthiopianDateTime(2017, 1, 1, 14, 5);
print(dt.format()); // 2017-01-01T14:05:00.000000
print(dt.format('dddd, MMMM Do YYYY')); // Wednesday, Meskerem 1st 2017
print(dt.format('h:mm A')); // 2:05 PM
print(dt.format('LLLL')); // Wednesday, Meskerem 1, 2017 2:05 PM
print(dt.format('[Meeting at] LT')); // Meeting at 2:05 PM
print(dt.format('MMMM', 'am')); // መስከረምAn ISO-8601-flavored string built from the Ethiopian date fields, e.g. '2017-01-01T14:05:30.123456'. UTC instants get a trailing Z. This is locale-independent by design — use format() with a pattern for anything locale-aware or human-facing.
print(EthiopianDateTime(2017, 1, 1)); // 2017-01-01T00:00:00.000000
print(EthiopianDateTime.utc(2017, 1, 1)); // 2017-01-01T00:00:00.000000ZFormats the time-of-day only using the traditional dawn-based 12-hour display convention described in Time, timezones, and the "dawn clock". This method never changes .year/.month/.day — it's purely a display transformation of the hour.
final dawn = EthiopianDateTime(2016, 1, 1, 6, 0, 0);
print(dawn.toEthiopianClockString()); // 12:00:00 day
print(dawn.toEthiopianClockString(amharic: true)); // 12:00:00 ቀን
final evening = EthiopianDateTime(2016, 1, 1, 18, 0, 0);
print(evening.toEthiopianClockString()); // 12:00:00 night(Static.) Parses input against a token format pattern, returning null on failure instead of throwing — including when the pattern matches but produces a calendrically invalid date (e.g. Pagume 30th).
final parsed = EthiopianDateTime.tryParse('2017-01-01 14:05:30', 'YYYY-MM-DD HH:mm:ss');
print(parsed); // 2017-01-01T14:05:30.000000
final invalid = EthiopianDateTime.tryParse('not a date', 'YYYY-MM-DD');
print(invalid); // nullOnly numeric tokens (YYYY, YY, MM, M, DD, D, HH, H, mm, m, ss, s, SSS) and [literal] text are supported for parsing — name-based tokens like MMMM/dddd are format-only.
There are two arithmetic APIs, for two different jobs:
add/subtracttake aDuration— a fixed span of real time. Use these when "3 days" or "90 minutes" unambiguously means the same span of wall-clock time regardless of which month it lands in.plus/minustake a count and anEthiopianTimeUnit— a calendar unit. Use these when "1 month" should mean "the same day next month" (with sensible clamping at month boundaries), not "exactly 30 days."
final dt = EthiopianDateTime(2017, 1, 1);
print(dt.add(const Duration(days: 10))); // 2017-01-11T00:00:00.000000
print(dt.subtract(const Duration(hours: 1))); // 2016-13-05T23:00:00.000000The Duration between this instant and other — a thin wrapper over the underlying DateTime.difference.
final a = EthiopianDateTime(2017, 1, 1);
final b = EthiopianDateTime(2017, 1, 11);
print(b.difference(a)); // 240:00:00.000000 (10 days as a Duration)enum EthiopianTimeUnit { year, month, week, day, hour, minute, second, millisecond, microsecond }Used by plus, minus, startOf, endOf, diff, and the unit-aware comparison methods below.
Adds or subtracts calendar units. year and month clamp the resulting day to the target month's length rather than overflowing — the same behavior you'd expect from "add one month" landing on the 28th/30th when the month is shorter.
final nehase30 = EthiopianDateTime(2016, 12, 30); // last day of Nehase, non-leap year
print(nehase30.plus(1, EthiopianTimeUnit.month));
// 2016-13-05T00:00:00.000000 — clamped to Pagume's 5th (and only) day range that year
final pagume5 = EthiopianDateTime(2016, 13, 5); // last day of the Ethiopian year
print(pagume5.plus(1, EthiopianTimeUnit.month));
// 2017-01-05T00:00:00.000000 — rolls cleanly into next year's Meskerem
final dt = EthiopianDateTime(2017, 4, 15);
print(dt.plus(1, EthiopianTimeUnit.year)); // same as plus(13, EthiopianTimeUnit.month)
print(dt.minus(2, EthiopianTimeUnit.week)); // 2017-04-01T00:00:00.000000Returns a copy truncated to the start of the given unit. week starts on Segno (Monday), matching the ISO week convention.
final dt = EthiopianDateTime(2017, 4, 15, 14, 30);
print(dt.startOf(EthiopianTimeUnit.year)); // 2017-01-01T00:00:00.000000
print(dt.startOf(EthiopianTimeUnit.month)); // 2017-04-01T00:00:00.000000
print(dt.startOf(EthiopianTimeUnit.day)); // 2017-04-15T00:00:00.000000
print(dt.startOf(EthiopianTimeUnit.week).weekday); // 1 (Monday)The last microsecond of the given unit (startOf of the next unit, minus one microsecond) — correctly accounts for Pagume's variable length.
final leapYearPagume = EthiopianDateTime(2015, 13, 2); // 2015 E.C. is a leap year
final end = leapYearPagume.endOf(EthiopianTimeUnit.month);
print((end.day, end.hour, end.minute, end.second)); // (6, 23, 59, 59) — Pagume has 6 days that yearEvery comparison method optionally accepts a EthiopianTimeUnit to compare at a coarser granularity (via startOf(unit) on both sides) instead of the exact instant.
final a = EthiopianDateTime(2017, 1, 1, 8);
final b = EthiopianDateTime(2017, 1, 1, 20);
print(a.isBefore(b)); // true (exact instant)
print(a.isBefore(b, EthiopianTimeUnit.day)); // false (same calendar day)
print(a.isAtSameMomentAs(EthiopianDateTime(2017, 1, 1, 8))); // trueprint(a.isSame(b, EthiopianTimeUnit.day)); // true
print(a.isSameOrBefore(b)); // true
print(b.isSameOrAfter(a)); // trueChecks whether this instant falls between two bounds, given in either chronological order. inclusivity uses two-character bracket notation: '()' (both exclusive, default), '[]' (both inclusive), '[)', '(]'.
final start = EthiopianDateTime(2017, 1, 1);
final mid = EthiopianDateTime(2017, 1, 5);
final end = EthiopianDateTime(2017, 1, 10);
print(mid.isBetween(start, end)); // true
print(start.isBetween(start, end)); // false (exclusive by default)
print(start.isBetween(start, end, inclusivity: '[]')); // true
print(mid.isBetween(end, start)); // true — order of bounds doesn't matterStandard Comparable implementation — EthiopianDateTime can be sorted directly with List.sort().
final dates = [
EthiopianDateTime(2017, 3, 1),
EthiopianDateTime(2016, 1, 1),
EthiopianDateTime(2018, 1, 1),
]..sort();diff(EthiopianDateTime other, [EthiopianTimeUnit unit = EthiopianTimeUnit.millisecond, bool asFloat = false])
The signed difference from other to this instant, in the given unit.
month and year count completed calendar units — the same "have we actually reached the same day-of-month yet" logic used for calculating someone's age in years, not a fixed-length division. Every other unit is a straightforward duration conversion, truncated toward zero unless asFloat is true.
final a = EthiopianDateTime(2016, 1, 1);
final b = EthiopianDateTime(2017, 1, 1);
print(b.diff(a, EthiopianTimeUnit.month)); // 13 (a full Ethiopian year is 13 months)
print(b.diff(a, EthiopianTimeUnit.year)); // 1
final c = EthiopianDateTime(2016, 1, 20);
print(c.diff(a, EthiopianTimeUnit.month)); // 0 — hasn't completed a full month yet
print(b.diff(a, EthiopianTimeUnit.day)); // whole days, truncated
print(b.diff(a, EthiopianTimeUnit.hour, true)); // fractional hours, e.g. 8760.0Humanized time from other to this instant.
final earlier = EthiopianDateTime(2017, 1, 1);
final later = earlier.plus(5, EthiopianTimeUnit.day);
print(later.from(earlier)); // in 5 days
print(earlier.from(later)); // 5 days ago
print(earlier.from(later, withoutSuffix: true)); // 5 days
print(earlier.from(later, locale: 'am')); // ከ5 ቀናት በፊትThe inverse direction of from.
print(later.to(earlier)); // 5 days ago
print(earlier.to(later)); // in 5 daysfromNow({bool withoutSuffix = false, String? locale}) / toNow({bool withoutSuffix = false, String? locale})
from/to relative to EthiopianDateTime.now().
final deadline = EthiopianDateTime(2017, 6, 1);
print(deadline.fromNow()); // e.g. "in 2 months" (relative to today)A contextual, calendar-aware string relative to referenceTime (defaults to now): "Today at ...", "Tomorrow at ...", "Yesterday at ...", a weekday name for the next/previous week, or a plain date otherwise.
final ref = EthiopianDateTime(2017, 1, 10, 15, 30);
print(ref.calendar(ref)); // Today at 3:30 PM
print(ref.plus(1, EthiopianTimeUnit.day).calendar(ref)); // Tomorrow at 3:30 PM
print(ref.minus(1, EthiopianTimeUnit.day).calendar(ref)); // Yesterday at 3:30 PM
print(EthiopianDateTime(2017, 6, 1).calendar(ref)); // 01/06/2017
print(ref.calendar(ref, 'am')); // ዛሬ 3:30 ከሰዓትoperator ==andhashCode— twoEthiopianDateTimes are equal iff their underlying instants are equal (same rules asDateTime ==).clone()— returns an equivalent copy. SinceEthiopianDateTimeis immutable, this exists mainly for API symmetry with libraries where cloning matters more.
final a = EthiopianDateTime(2017, 1, 1, 12, 30);
final b = EthiopianDateTime(2017, 1, 1, 12, 30);
print(a == b); // true
print(a.hashCode == b.hashCode); // true
print(a.clone() == a); // trueThe earliest/latest instant in a non-empty list. Throws ArgumentError on an empty list.
final dates = [
EthiopianDateTime(2017, 1, 1),
EthiopianDateTime(2016, 1, 1),
EthiopianDateTime(2018, 1, 1),
];
print(EthiopianDateTime.min(dates)); // 2016-01-01T...
print(EthiopianDateTime.max(dates)); // 2018-01-01T...Render .year, .month, and .day as traditional Ge'ez (Ethiopic) numerals — the additive numeral system used in Ge'ez, Amharic, and Tigrinya religious, historical, and formally-styled texts (church calendars, chapter/verse numbers, page numbers, formally-printed calendar years).
final dt = EthiopianDateTime(2017, 1, 21);
print(dt.yearGeez); // ፳፻፲፯
print(dt.monthGeez); // ፩
print(dt.dayGeez); // ፳፩There are no equivalent getters for hour/minute/second: the traditional Ge'ez numeral system has no digit for zero, so it can't faithfully represent a clock value that might be 0 — see toGeezNumeral below for the full explanation. Ge'ez numerals are only ever applied here to calendar fields that are always positive by construction.
Also available as format tokens Yg/Mg/Dg, composable with everything else format() supports:
print(dt.format('Dg MMMM Yg')); // ፳፩ Meskerem ፳፻፲፯
print(dt.format('[Year] Yg')); // Year ፳፻፲፯Yg/Mg/Dg are format-only, like MMMM/dddd — not supported by tryParse().
Converts a positive integer to Ge'ez numerals. Implements the algorithm standardized by the W3C in CSS Counter Styles Level 3 — the same one browsers use for list-style-type: ethiopic-numeric — rather than an informally-derived approximation, and was checked against the spec's own worked examples plus round-trip fuzz testing across tens of thousands of values (see Correctness & testing).
print(toGeezNumeral(1)); // ፩
print(toGeezNumeral(100)); // ፻
print(toGeezNumeral(1000)); // ፲፻ ("ten hundred" — the classic beginner surprise)
print(toGeezNumeral(2017)); // ፳፻፲፯Throws ArgumentError for 0 or a negative number — the traditional Ge'ez system has no zero, a real limitation of the numeral system itself, not an oversight in this implementation. This is also why this package only ever applies Ge'ez numerals to year/month/day, never to hour/minute/second.
The exact inverse of toGeezNumeral — every value it produces parses back to the original integer. Returns null (never throws) for malformed input.
print(tryParseGeezNumeral('፳፻፲፯')); // 2017
print(tryParseGeezNumeral('not geez')); // nullThe primitives EthiopianDateTime is built on — exported directly for anyone who needs raw calendar math without the object overhead (bulk data processing, building your own abstractions, etc.).
Converts a proleptic-Gregorian date to a Julian Day Number.
print(gregorianToJdn(2024, 9, 11)); // 2460565The inverse of gregorianToJdn.
print(jdnToGregorian(2460565)); // (2024, 9, 11)Converts an Ethiopian date to a Julian Day Number.
print(ethiopianToJdn(2017, 1, 1)); // matches gregorianToJdn(2024, 9, 11)The inverse of ethiopianToJdn.
print(isEthiopianLeapYear(2015)); // true
print(isEthiopianLeapYear(2016)); // false30 for months 1–12; 5 or 6 for month 13 (Pagume) depending on leap-year status. Throws RangeError for a month outside 1..13.
print(daysInEthiopianMonth(2015, 13)); // 6
print(daysInEthiopianMonth(2016, 13)); // 5The calibrated Julian Day Number offset the whole Ethiopian↔JDN conversion is built on. Exposed for transparency and for anyone building their own calendar math on top of the same epoch — see Correctness & testing for how it was calibrated.
EthiopianDateTime.format() and .tryParse() are thin wrappers over these — use the top-level functions directly if you're formatting/parsing without constructing an object first, or building your own tooling on top.
Identical behavior to dt.format(pattern, locale).
Identical behavior to EthiopianDateTime.tryParse(input, pattern, utc: utc).
The engine behind from/to/fromNow/toNow. Useful directly whenever you already have a Duration and don't need it tied to a specific EthiopianDateTime pair (e.g. a countdown timer, a generic "time elapsed" display).
print(humanizeDuration(const Duration(minutes: 5))); // in 5 minutes
print(humanizeDuration(const Duration(days: -3))); // 3 days ago
print(humanizeDuration(const Duration(hours: 2), withoutSuffix: true)); // 2 hours
print(humanizeDuration(const Duration(days: 5), locale: 'am')); // በ5 ቀናት ውስጥThe thresholds that decide which phrase to use (when "5 minutes" becomes "an hour," when "20 hours" becomes "a day," and so on) are fixed, well-established defaults — not something you need to tune per locale; only the phrase text changes by language.
| Elapsed time | Phrase |
|---|---|
| 0–44 seconds | a few seconds |
| 45–89 seconds | a minute |
| 90 sec – 44 min | N minutes |
| 45–89 minutes | an hour |
| 90 min – 21 hours | N hours |
| 22–35 hours | a day |
| 36 hours – 25 days | N days |
| 26–45 days | a month |
| 46 days – 10 months | N months |
| 11–17 months | a year |
| 18+ months | N years |
Every user-facing string in this package — month/weekday names, AM/PM, ordinal day suffixes, relative-time phrases, and calendar() templates — is resolved through a small, explicit locale registry.
The data shape one language needs to supply. You only need to construct one of these yourself if you're adding a language beyond the three built in.
class EthiopianLocale {
final String code; // e.g. 'fr'
final List<String> months; // 14 entries, index 0 unused, 1-13 = Meskerem..Pagume
final List<String> monthsShort;
final List<String> weekdays; // 8 entries, index 0 unused, 1=Monday..7=Sunday
final List<String> weekdaysShort;
final List<String> weekdaysMin;
final String am;
final String pm;
final String Function(int day) ordinal; // day -> '1st', or plain digits if N/A
final EthiopianRelativeTimePhrases relativeTime;
final EthiopianCalendarPhrases calendar;
}The phrase-template shapes referenced above — future/past wrappers with a %s placeholder, counted phrases with a %d placeholder, and the calendar() templates (sameDay, nextDay, lastDay, nextWeek, lastWeek, sameElse). See the source of the built-in locales for a complete worked example of every field.
class EthiopianLocales {
static String defaultLocale; // read/write, defaults to 'en'
static void register(EthiopianLocale locale); // add or replace a locale by its .code
static Iterable<String> get available; // all registered codes
static bool isSupported(String code);
static EthiopianLocale resolve([String? code]); // code -> defaultLocale -> English, in that order
}Per-call locale selection:
final dt = EthiopianDateTime(2017, 1, 1);
print(dt.format('MMMM', 'am')); // መስከረም
print(dt.format('MMMM', 'ti')); // መስከረም (differs from Amharic starting month 2 onward)Global default, when you don't want to pass locale on every call:
EthiopianLocales.defaultLocale = 'am';
print(dt.format('MMMM')); // መስከረም — no per-call locale argument neededRegistering your own locale — the whole point of exposing this as a registry rather than a fixed enum:
final french = EthiopianLocale(
code: 'fr',
months: const ['', 'Meskerem', /* ... */],
// ...
ordinal: (day) => '$day',
relativeTime: const EthiopianRelativeTimePhrases(
future: 'dans %s', past: 'il y a %s',
fewSeconds: 'quelques secondes', aMinute: 'une minute', minutes: '%d minutes',
anHour: 'une heure', hours: '%d heures', aDay: 'un jour', days: '%d jours',
aMonth: 'un mois', months: '%d mois', aYear: 'un an', years: '%d ans',
),
calendar: const EthiopianCalendarPhrases(
sameDay: "[Aujourd'hui à] %s", nextDay: '[Demain à] %s', lastDay: '[Hier à] %s',
nextWeek: 'dddd [à] %s', lastWeek: '[La semaine dernière] dddd [à] %s',
),
);
EthiopianLocales.register(french);
print(dt.format('MMMM', 'fr'));Nothing else in the package needs to change — format(), calendar(), and every relative-time method immediately understand any locale you register.
Built-in locales, also exported directly if you want to inspect or extend them programmatically: ethiopianLocaleEn, ethiopianLocaleAm, ethiopianLocaleTi.
| Token | Meaning | Example |
|---|---|---|
YYYY |
4-digit year | 2017 |
YY |
2-digit year | 17 (parses back with a 2000s century) |
MMMM |
Full month name | Meskerem |
MMM |
Abbreviated month name | Mes |
MM |
2-digit month | 01 |
M |
Month | 1 |
DD |
2-digit day of month | 01 |
D |
Day of month | 1 |
Do |
Day of month, ordinal (English only — plain digits for other locales) | 1st |
dddd |
Full weekday name | Wednesday |
ddd |
Abbreviated weekday name | Wed |
dd |
Minimal weekday name | We |
d |
ISO weekday number, 1–7 |
3 |
HH |
2-digit 24-hour | 14 |
H |
24-hour | 14 |
hh |
2-digit 12-hour | 02 |
h |
12-hour | 2 |
mm |
2-digit minute | 05 |
m |
Minute | 5 |
ss |
2-digit second | 09 |
s |
Second | 9 |
SSS |
Millisecond, 3 digits | 123 |
A |
AM/PM (locale-aware) | PM |
a |
am/pm, lowercase (locale-aware) | pm |
[literal text] |
Passed through unchanged, tokens inside are not expanded | [at] → at |
Yg |
Year as a Ge'ez numeral | ፳፻፲፯ |
Mg |
Month as a Ge'ez numeral | ፩ |
Dg |
Day of month as a Ge'ez numeral | ፳፩ |
Localized presets (composable inside a larger pattern, e.g. '[Meeting at] LT'):
| Preset | Expands to |
|---|---|
LT |
h:mm A |
LTS |
h:mm:ss A |
L |
DD/MM/YYYY |
LL |
MMMM D, YYYY |
LLL |
MMMM D, YYYY h:mm A |
LLLL |
dddd, MMMM D, YYYY h:mm A |
Display a user's birthday in their preferred language:
String formatBirthday(EthiopianDateTime birthday, String userLocale) =>
birthday.format('MMMM D, YYYY', userLocale);A "posted 3 hours ago" timestamp for a social feed:
String postedAt(DateTime postedTime) =>
EthiopianDateTime.fromDateTime(postedTime).fromNow();Generate every day of the current Ethiopian month (for a calendar grid):
List<EthiopianDateTime> daysInCurrentMonth() {
final start = EthiopianDateTime.now().startOf(EthiopianTimeUnit.month);
return List.generate(
start.daysInMonth,
(i) => start.plus(i, EthiopianTimeUnit.day),
);
}Check whether a subscription is still within its billing period:
bool isActive(EthiopianDateTime periodStart, EthiopianDateTime now) =>
now.isBefore(periodStart.plus(1, EthiopianTimeUnit.month));Switch the whole app's date display language at runtime:
void onUserChangedLanguage(String languageCode) {
EthiopianLocales.defaultLocale = languageCode;
// every subsequent format()/calendar()/fromNow() call with no explicit
// locale argument now uses the new language automatically
}A compact, structured summary for tools generating code against this package.
PACKAGE: ethiopian_datetime_converter
IMPORT: package:ethiopian_datetime_converter/ethiopian_datetime_converter.dart
CORE TYPE: EthiopianDateTime (immutable; every "mutating" method returns a NEW instance)
Constructors:
EthiopianDateTime(year, month, [day=1, hour=0, minute=0, second=0, millisecond=0, microsecond=0]) // local
EthiopianDateTime.utc(...same params...) // UTC
EthiopianDateTime.fromDateTime(DateTime) // wrap existing DateTime
EthiopianDateTime.now({utc: bool})
Throws ArgumentError if month not in 1..13, or day out of range for that month/year (Pagume = month 13, has 5 or 6 days).
MONTH NUMBERING: 1=Meskerem ... 13=Pagume (NOT 0-indexed).
WEEKDAY NUMBERING: 1=Monday ... 7=Sunday (matches core Dart DateTime.weekday, NOT 0-indexed, NOT Sunday-first).
LEAP YEAR RULE: year % 4 == 3 (no century exception, unlike Gregorian).
Read fields: .year .month .day .hour .minute .second .millisecond .microsecond
.weekday .isUtc .isLeapYear .daysInMonth
.monthNameEnglish .monthNameAmharic .weekdayNameEnglish .weekdayNameAmharic
.yearGeez .monthGeez .dayGeez (Ge'ez/Ethiopic numeral strings -- see GE'EZ NUMERALS below)
.microsecondsSinceEpoch
Convert back: .toDateTime() -> DateTime (the escape hatch to the rest of the Dart ecosystem)
Format: .format([pattern, locale]) -> String (null pattern == .toString())
.toString() -> String (Ethiopian-calendar ISO-8601-ish, locale-independent)
.toEthiopianClockString({amharic: bool}) -> String (dawn-clock display ONLY, never changes the date)
Parse: EthiopianDateTime.tryParse(input, pattern, {utc: bool}) -> EthiopianDateTime? (null on failure, never throws)
Arithmetic:
.add(Duration) / .subtract(Duration) -- fixed real-time span
.plus(int amount, EthiopianTimeUnit unit) / .minus(...) -- calendar unit, clamps month-end
.difference(other) -> Duration
EthiopianTimeUnit enum: year, month, week, day, hour, minute, second, millisecond, microsecond
Navigate: .startOf(EthiopianTimeUnit) / .endOf(EthiopianTimeUnit) -> EthiopianDateTime
Compare: .isBefore/.isAfter/.isSame/.isSameOrBefore/.isSameOrAfter(other, [EthiopianTimeUnit? unit])
.isBetween(a, b, {unit, inclusivity: '()'|'[]'|'[)'|'(]'})
.compareTo(other) -> int (Comparable<EthiopianDateTime>, sortable with List.sort())
== / hashCode (equal iff same instant)
Diff: .diff(other, [unit=millisecond, asFloat=false]) -> num
-- month/year count COMPLETED units (age-style), not fixed-length division
Relative time: .from(other, {withoutSuffix, locale}) / .to(other, {...})
.fromNow({withoutSuffix, locale}) / .toNow({...})
.calendar([referenceTime, locale]) -> String
Static utilities: EthiopianDateTime.min(List<EthiopianDateTime>) / .max(...) -- throws on empty list
Misc: .clone() -> EthiopianDateTime
TOP-LEVEL FUNCTIONS (no object needed):
gregorianToJdn(year, month, day) -> int
jdnToGregorian(jdn) -> (int, int, int) // record type: (year, month, day)
ethiopianToJdn(year, month, day) -> int
jdnToEthiopian(jdn) -> (int, int, int)
isEthiopianLeapYear(year) -> bool
daysInEthiopianMonth(year, month) -> int // throws RangeError if month not in 1..13
formatEthiopian(EthiopianDateTime, [pattern, locale]) -> String
parseEthiopian(input, pattern, {utc}) -> EthiopianDateTime?
humanizeDuration(Duration, {withoutSuffix, locale}) -> String
ethiopicEpochOffset -> int constant
LOCALE SYSTEM:
EthiopianLocales.defaultLocale: String // mutable global, default 'en'
EthiopianLocales.register(EthiopianLocale) // add/replace by .code
EthiopianLocales.resolve([code]) -> EthiopianLocale // code ?? defaultLocale, falls back to English
EthiopianLocales.isSupported(code) -> bool
EthiopianLocales.available -> Iterable<String>
Built in: ethiopianLocaleEn, ethiopianLocaleAm ('am'), ethiopianLocaleTi ('ti')
Every locale-aware method takes locale as its LAST parameter, optional, defaults to null -> resolves to defaultLocale.
GE'EZ NUMERALS (traditional Ethiopic additive numeral system, e.g. 2017 -> ፳፻፲፯):
toGeezNumeral(int number) -> String // throws ArgumentError for number < 1 -- THE SYSTEM HAS NO ZERO
tryParseGeezNumeral(String input) -> int? // null on malformed input, never throws; exact inverse of toGeezNumeral
EthiopianDateTime getters: .yearGeez .monthGeez .dayGeez -> String
Format tokens: Yg (year) Mg (month) Dg (day) -- format-only, NOT parseable, same as MMMM/dddd
There is deliberately no Ge'ez rendering for hour/minute/second: those can be 0, and the traditional
numeral system has no digit for zero, so there is no faithful representation to fall back to.
FORMAT TOKENS: YYYY YY MMMM MMM MM M DD D Do dddd ddd dd d HH H hh h mm m ss s SSS A a Yg Mg Dg
PRESETS (composable): LT LTS L LL LLL LLLL
LITERAL ESCAPE: [any text] is passed through unchanged.
PARSE-SUPPORTED TOKENS: numeric only (YYYY YY MM M DD D HH H mm m ss s SSS) + [literals]. Not MMMM/dddd/Yg/Mg/Dg/etc.
COMMON MISTAKES TO AVOID WHEN GENERATING CODE AGAINST THIS PACKAGE:
- Do not treat .hour as needing dawn-clock conversion for storage/comparison/arithmetic — it is the plain civil hour.
toEthiopianClockString() is ONLY for display.
- Do not assume month is 0-indexed. 1 = Meskerem, 13 = Pagume.
- Do not construct Pagume dates without checking daysInEthiopianMonth(year, 13) first if the day number
isn't already known-valid — it's 5 in a non-leap year, 6 in a leap year, and the constructor throws otherwise.
- tryParse/parseEthiopian return null on failure; they do not throw. The constructors DO throw ArgumentError
for invalid calendar dates -- catch accordingly depending on which entry point you used.
- "1 month" via .plus()/.minus() is a calendar unit with clamping, not a fixed 30-day Duration. Use
.add(Duration)/.subtract(Duration) if you specifically want a fixed real-time span instead.
- toGeezNumeral()/.yearGeez/.monthGeez/.dayGeez throw/are undefined for 0 -- there is no Ge'ez zero.
Never call toGeezNumeral on an hour/minute/second value; those can legitimately be 0.
The conversion algorithm is not asserted to work — it's checked, and you can re-run the checks yourself:
- Exhaustive round-trip testing: every valid Ethiopian date across a multi-century range converts to Gregorian and back to the exact same date, with zero mismatches — including every edge case around Pagume's variable length (5 vs. 6 days).
- Real-world calibration: the epoch constant the whole Ethiopian↔JDN conversion is built on is checked against multiple independently-reported real Ethiopian New Year dates (not derived from a single source and trusted), including the specific case where the New Year date shifts by a day following a leap year.
- Midnight-boundary testing: confirms the Ethiopian calendar date rolls over at exactly the same instant as the Gregorian date (not shifted by the dawn-clock convention — see Time, timezones, and the "dawn clock").
- Ge'ez numeral conversion:
toGeezNumeral/tryParseGeezNumeralimplement the W3C's standardizedethiopic-numericalgorithm, checked against the spec's own worked examples and round-trip fuzz tested (every integer converts to Ge'ez and back to the exact same value) across thousands of values, including the edge cases around implicit coefficients before a bare፻/፼that a naive implementation is likely to get wrong.
Run the full suite yourself:
dart test
Every core conversion (gregorianToJdn, jdnToGregorian, ethiopianToJdn, jdnToEthiopian) is O(1) — fixed-count integer arithmetic with no loops, no recursion, and no floating-point rounding. There is no algorithmic cost that scales with the year, the date range, or the number of conversions you perform in a batch.
- Implements the modern civil Ethiopian calendar. Historical Amete Alem-era reckoning and pre-standardization local variations are out of scope.
- On Dart web builds,
DateTime(and therefore this package) only has millisecond precision, not microsecond — a platform limitation this package inherits rather than works around. - This is a calendar library, not a timezone library — it assumes you've already decided what timezone your
DateTimeis in, exactly the wayDateTimeitself does. diff()/humanizeDuration()month and year lengths are necessarily approximations for unit conversion purposes (there's no such thing as an exact "month" measured in microseconds) — don't use them for billing-cycle-exact calculations; use explicit day/hour/microsecond units instead when exactness matters.- Three locales ship built in (English, Amharic, Tigrinya); anything beyond that requires registering your own
EthiopianLocale— see The locale system.
Issues and pull requests are welcome — especially native-speaker review of the Amharic and Tigrinya relative-time/calendar phrasing, and additional locales.
See LICENSE.