DEV Community

Cover image for Building an Ethiopian Calendar in Flutter
Muhammad Uzair
Muhammad Uzair

Posted on Originally published at uzairxdev.online

Building an Ethiopian Calendar in Flutter

Most date libraries pretend the world runs on one calendar. Building the Zemen App — my Ethiopian calendar and date converter on the Play Store — taught me otherwise. The app is 100% offline: it converts between the Ethiopian and Gregorian calendars, tracks national holidays like Enkutatash, Meskel, and Timkat, and carries a full database of Orthodox Tewahedo fasting days, all without a single network call.

The heart of the app is a small, self-contained calendar engine I wrote in Dart. It converts dates in both directions with exact integer math, models the 13-month Ethiopian year including leap years, and is covered by anchor-based tests. This article walks through how that engine works, so you can build the same thing — or adapt the approach to any non-Gregorian calendar.

How the Ethiopian calendar works

The Ethiopian calendar has 13 months: twelve months of exactly 30 days each, followed by Pagume, a short 13th month with 5 days — or 6 in a leap year. A leap year is any year divisible by 4, the same alignment as the Julian calendar. The new year, Enkutatash, falls on September 11 in most years (September 12 in the year before a Gregorian leap year). The calendar runs roughly seven to eight years behind the Gregorian calendar — as I write this in October 2026, it is 2019 in Ethiopia.

That regularity is what makes the calendar engine simple: every month except Pagume has a fixed length, so month arithmetic is multiplication, not lookup tables.

The one algorithm you need: the Julian Day Number

Instead of converting directly between the two calendars, convert through a neutral pivot: the Julian Day Number (JDN), the count of days since a fixed astronomical epoch. Ethiopian date → JDN → Gregorian date, and back. Each direction is one pure function, which makes the code trivially testable.

The Ethiopian epoch — 1 Meskerem 1 EE — corresponds to JDN 1724221. Days elapsed before the start of a given Ethiopian year are:

\dart
/// Days elapsed before 1 Meskerem of [year].
int daysBeforeEthiopianYear(int year) => 365 * (year - 1) + (year - 1) ~/ 4;
\
\

Because Ethiopian leap years are exactly the years divisible by 4, the count of leap years before year Y is (Y - 1) ~/ 4\. The forward conversion is then one line:

\dart
int ethiopianToJdn(int year, int month, int day) =>
1724221 + daysBeforeEthiopianYear(year) + 30 * (month - 1) + (day - 1);
\
\

The reverse direction recovers the year with integer division over 4-year cycles of 1461 days (365 × 4 + 1):

\dart
/// Converts a Julian Day Number to an Ethiopian [year, month, day] triple.
List<int> jdnToEthiopian(int jdn) {
final days = jdn - 1724221; // days since 1 Meskerem 1 EE
final year = 1 + (4 * days + 3) ~/ 1461;
final dayOfYear = days - daysBeforeEthiopianYear(year);
return [year, dayOfYear ~/ 30 + 1, dayOfYear % 30 + 1];
}
\
\

For the Gregorian side, the classic pair of JDN algorithms does the job:

\`dart
int gregorianToJdn(int year, int month, int day) {
final a = (14 - month) ~/ 12;
final y = year + 4800 - a;
final m = month + 12 * a - 3;
return day +
(153 * m + 2) ~/ 5 +
365 * y +
y ~/ 4 -
y ~/ 100 +
y ~/ 400 -
32045;
}

List jdnToGregorian(int jdn) {
var l = jdn + 68569;
final n = (4 * l) ~/ 146097;
l = l - (146097 * n + 3) ~/ 4;
final i = (4000 * (l + 1)) ~/ 1461001;
l = l - (1461 * i) ~/ 4 + 31;
final j = (80 * l) ~/ 2447;
final day = l - (2447 * j) ~/ 80;
l = j ~/ 11;
final month = j + 2 - 12 * l;
final year = 100 * (n - 49) + i + l;
return [year, month, day];
}
`\

All four functions use only integer arithmetic — no floating point, no DateTime, no timezone surprises. That matters on mobile: the engine behaves identically on every device, and it is fast enough to convert thousands of dates in a single frame if a calendar grid needs it.

Wrap it in a tiny public API:

\`dart
class EthiopianCalendar {
const EthiopianCalendar._();

/// Converts an Ethiopian date to [year, month, day] in the Gregorian calendar.
static List toGregorian(int year, int month, int day) =>
jdnToGregorian(ethiopianToJdn(year, month, day));

/// Converts a Gregorian date to [year, month, day] in the Ethiopian calendar.
static List fromGregorian(int year, int month, int day) =>
jdnToEthiopian(gregorianToJdn(year, month, day));

/// True when [year] is an Ethiopian leap year (Pagume has 6 days).
static bool isLeapYear(int year) => year % 4 == 0;

/// Days in [month] of Ethiopian [year].
static int daysInMonth(int year, int month) =>
month < 13 ? 30 : (isLeapYear(year) ? 6 : 5);
}
`\

Month names and the weekday trick

For the UI, keep a simple constant list of the thirteen month names — Meskerem, Tikimt, Hidar, Tahsas, Tir, Yekatit, Megabit, Miyazya, Ginbot, Sene, Hamle, Nehase, Pagume. Because month lengths are fixed, rendering a month grid is pure arithmetic: the starting weekday plus the month length, both derived from the JDN. No per-month tables, no special cases — the only conditional in the whole engine is Pagume.

The weekday itself comes free from the JDN: (jdn + 1) % 7\ gives the weekday with 0 as Sunday. That one-liner tells your grid which column the 1st of the month lands in, with no DateTime weekday lookups.

Why I wrote the engine instead of using a package

There are date-conversion packages on pub.dev, but a calendar app lives or dies on correctness, and a hand-rolled engine has three advantages. First, every line is covered by your own anchor tests, so you know exactly what is verified. Second, there are no transitive dependencies to audit or keep updated. Third, the whole engine is under 60 lines — small enough to fully understand, which matters when a user reports that a holiday landed on the wrong day. For a feature this central, that understanding is worth more than the hour a package saves, and it keeps the app free of extra native date libraries and method channels.

Making it 100% offline

Date conversion is only half of a calendar app. The Zemen App also shows national holidays (Enkutatash, Meskel, Timkat) and the full schedule of Orthodox Tewahedo fasting days and Sabbath days. None of that can be computed from the conversion math — it is data, and in an offline-first app, data ships with the app.

I bundled the holiday and fasting database as a local asset and load it once at startup. Lookups are then instant and work in airplane mode, in areas with no signal, and without costing the user a byte of mobile data. That decision shaped the whole architecture: the app has no backend at all, which means no server costs, no API to maintain, and no loading spinners anywhere in the UI. If you are building an offline-first app, I wrote a deeper guide on architecting offline-first mobile apps.

Testing date math with anchors

Calendar code fails in the most embarrassing way possible: off-by-one errors that only surface on specific dates. The fix is anchor-based testing — asserting conversions against dates you know are correct:

\`dart
test('known anchor dates convert exactly', () {
// Enkutatash (Ethiopian New Year)
expect(EthiopianCalendar.toGregorian(2018, 1, 1), [2025, 9, 11]);
expect(EthiopianCalendar.toGregorian(2016, 1, 1), [2023, 9, 11]);
// Pagume's 6th day exists only in leap years (year divisible by 4)
expect(EthiopianCalendar.toGregorian(2016, 13, 6), [2024, 9, 10]);
expect(EthiopianCalendar.fromGregorian(2026, 1, 7), [2018, 4, 29]); // Genna
expect(EthiopianCalendar.fromGregorian(2026, 1, 19), [2018, 5, 11]); // Timkat
});

test('every date round-trips through both calendars', () {
var jdn = gregorianToJdn(1990, 1, 1);
final end = gregorianToJdn(2040, 12, 31);
while (jdn <= end) {
final eth = jdnToEthiopian(jdn);
expect(ethiopianToJdn(eth[0], eth[1], eth[2]), jdn);
jdn++;
}
});
`\

The round-trip test is the real workhorse: it walks every single day across fifty years and asserts that converting to Ethiopian and back lands on the exact same Julian Day Number. When I first wrote this engine, that test caught a leap-year counting bug that only appeared in years divisible by 4 — exactly the kind of bug a handful of spot checks would miss.

Keep the engine in its own file with zero Flutter imports. Pure Dart means the tests run in milliseconds with flutter test\, and the same engine works anywhere — a widget, a background isolate, or a server.

Putting it on screen

With the engine done, the UI is straightforward. A calendar page needs three things from it: today's Ethiopian date, the month grid for any displayed month, and holiday lookups from the bundled database. Because everything is synchronous integer math, you can compute the whole visible month inside build\ without futures, streams, or caching layers:

\dart
final today = DateTime.now();
final eth = EthiopianCalendar.fromGregorian(today.year, today.month, today.day);
Text('\${eth[2]} \${monthNames[eth[1] - 1]} \${eth[0]}');
\
\

Separating the engine from the widgets also keeps the architecture clean — the UI layer never does date math, it only renders what the engine returns. That separation is the same pragmatic layering I describe in Flutter Clean Architecture: A Pragmatic Folder Structure.

If you want to see how all of this looks in a shipped product, the Zemen App case study has the full breakdown of the app, including its offline design and Play Store listing.

Get in touch and let's talk about your project.


Originally published at uzairxdev.online

Top comments (0)