diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..754d3d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + sdk: [stable, beta] + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: ${{ matrix.sdk }} + - run: dart pub get + - run: dart analyze + - run: dart test + - run: dart format --set-exit-if-changed . + + publish-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - run: dart pub get + - run: dart pub publish --dry-run diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a909c98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Dart/Flutter +.dart_tool/ +.packages +build/ +pubspec.lock +doc/api/ + +# IDE +.idea/ +*.iml +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# AI agents +.claude/ +.vscode/* +.codex/ +.cursor/ +.aider/ +.aider.chat.history.md +.continue/ +.windsurf/ +.gemini/ +.codeium/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f3add7a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## 1.0.0 + +### Added + +- Umm al-Qura (UAQ) engine: table-driven conversion for Hijri years 1318-1500 +- FCNA engine: astronomical new moon calculation using Meeus Ch. 49 +- Pluggable engine registry with `registerCalendar()` and `getCalendar()` +- Top-level convenience functions: `toHijri()`, `toGregorian()`, `isValidHijriDate()`, `daysInHijriMonth()` +- Hijri month names in long, medium, and short forms +- Hijri weekday names in long, short, and numeric forms +- Full 184-entry Umm al-Qura reference table (1318-1501 H) +- Zero external dependencies diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ae51aa --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# hijri_core + +[![pub package](https://img.shields.io/pub/v/hijri_core.svg)](https://pub.dev/packages/hijri_core) +[![CI](https://github.com/acamarata/hijri-core-dart/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/hijri-core-dart/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +Hijri/Gregorian calendar conversion for Dart and Flutter. Zero dependencies. + +## Installation + +```yaml +dependencies: + hijri_core: ^1.0.0 +``` + +## Quick Start + +```dart +import 'package:hijri_core/hijri_core.dart'; + +// Gregorian to Hijri (Umm al-Qura by default) +final hijri = toHijri(DateTime.utc(2025, 3, 1)); +print('${hijri!.hy}/${hijri.hm}/${hijri.hd}'); // 1446/9/1 + +// Hijri to Gregorian +final greg = toGregorian(1446, 9, 1); +print(greg!.toIso8601String().substring(0, 10)); // 2025-03-01 + +// Use FCNA calendar instead +final fcna = toHijri( + DateTime.utc(2025, 3, 1), + options: const ConversionOptions(calendar: 'fcna'), +); + +// Validate a Hijri date +final valid = isValidHijriDate(1444, 9, 1); // true + +// Days in a Hijri month +final days = daysInHijriMonth(1444, 9); // 29 +``` + +## API + +### Top-Level Functions + +| Function | Description | +| --- | --- | +| `toHijri(DateTime date, {ConversionOptions? options})` | Convert Gregorian to Hijri. Returns `HijriDate?`. | +| `toGregorian(int hy, int hm, int hd, {ConversionOptions? options})` | Convert Hijri to Gregorian. Returns `DateTime?` (UTC). | +| `isValidHijriDate(int hy, int hm, int hd, {ConversionOptions? options})` | Check if a Hijri date is valid. Returns `bool`. | +| `daysInHijriMonth(int hy, int hm, {ConversionOptions? options})` | Days in a Hijri month (29 or 30). Throws `RangeError` if out of range. | + +### Registry Functions + +| Function | Description | +| --- | --- | +| `registerCalendar(String name, CalendarEngine engine)` | Register a custom calendar engine. | +| `getCalendar(String name)` | Retrieve a registered engine by name. | +| `listCalendars()` | List all registered engine names. | + +### Data + +| Export | Description | +| --- | --- | +| `hDatesTable` | 184-entry Umm al-Qura reference table (Hijri 1318-1501). | +| `hmLong`, `hmMedium`, `hmShort` | Hijri month names (12 entries each). | +| `hwLong`, `hwShort`, `hwNumeric` | Hijri weekday names (7 entries each). | + +## Engines + +### UAQ (Umm al-Qura) + +The official Saudi Arabian Islamic calendar. Table-driven conversions covering Hijri years 1318-1500 (Gregorian 1900-2076). Returns `null` for dates outside that range. + +This is the default engine. + +### FCNA (Fiqh Council of North America) + +Astronomical calculation using Meeus Chapter 49 new moon algorithm. The FCNA criterion: if the new moon conjunction occurs before 12:00 noon UTC on day D, the new Hijri month begins at midnight starting day D+1. Otherwise it begins at midnight starting day D+2. + +No fixed date range. Works for any Hijri year >= 1. + +```dart +final h = toHijri( + DateTime.utc(2025, 3, 1), + options: const ConversionOptions(calendar: 'fcna'), +); +``` + +## Custom Engine + +Implement the `CalendarEngine` abstract class and register it: + +```dart +class MyEngine extends CalendarEngine { + @override + String get id => 'custom'; + + @override + HijriDate? toHijri(DateTime date) { /* ... */ } + + @override + DateTime? toGregorian(int hy, int hm, int hd) { /* ... */ } + + @override + bool isValid(int hy, int hm, int hd) { /* ... */ } + + @override + int daysInMonth(int hy, int hm) { /* ... */ } +} + +registerCalendar('custom', MyEngine()); + +final h = toHijri( + DateTime.utc(2025, 1, 1), + options: const ConversionOptions(calendar: 'custom'), +); +``` + +## Compatibility + +- Dart SDK >= 3.7.0 +- Works with Flutter +- Zero external dependencies + +## Related Packages + +- [hijri-core](https://www.npmjs.com/package/hijri-core) (TypeScript/npm) +- [nrel-spa](https://www.npmjs.com/package/nrel-spa) (Solar position algorithm) +- [pray-calc](https://www.npmjs.com/package/pray-calc) (Islamic prayer times) + +## License + +MIT. Copyright (c) 2026 Aric Camarata. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..572dd23 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml diff --git a/lib/hijri_core.dart b/lib/hijri_core.dart new file mode 100644 index 0000000..3f5b06f --- /dev/null +++ b/lib/hijri_core.dart @@ -0,0 +1,28 @@ +/// Hijri/Gregorian calendar conversion for Dart and Flutter. +/// +/// Pluggable engine system with built-in Umm al-Qura (UAQ) and FCNA calendars. +/// Zero dependencies. +library; + +// Types +export 'src/types.dart'; + +// Constants +export 'src/constants.dart'; + +// Data +export 'src/data.dart'; + +// Registry +export 'src/registry.dart'; + +// Names +export 'src/names/months.dart'; +export 'src/names/weekdays.dart'; + +// Engines (for direct access) +export 'src/engines/uaq.dart' show uaqEngine; +export 'src/engines/fcna.dart' show fcnaEngine; + +// Top-level convenience API +export 'src/hijri_core_api.dart'; diff --git a/lib/src/constants.dart b/lib/src/constants.dart new file mode 100644 index 0000000..c21c0e7 --- /dev/null +++ b/lib/src/constants.dart @@ -0,0 +1,5 @@ +/// Milliseconds in one day. +const int msPerDay = 86400000; + +/// Number of months in a Hijri year. +const int monthsPerYear = 12; diff --git a/lib/src/data.dart b/lib/src/data.dart new file mode 100644 index 0000000..f7cf18e --- /dev/null +++ b/lib/src/data.dart @@ -0,0 +1,192 @@ +import 'types.dart'; + +/// Umm al-Qura reference table: Hijri years 1318-1501. +/// Each entry records the 1 Muharram Gregorian date and a 12-bit days-per-month +/// bitmask. Bit i (0-indexed from bit 0) corresponds to month i+1: 1 = 30 days, +/// 0 = 29 days. The final sentinel entry (hy 1501, dpm 0) marks the upper bound. +final List hDatesTable = const [ + HijriYearRecord(hy: 1318, dpm: 0x02ea, gy: 1900, gm: 4, gd: 30), + HijriYearRecord(hy: 1319, dpm: 0x06e9, gy: 1901, gm: 4, gd: 19), + HijriYearRecord(hy: 1320, dpm: 0x0ed2, gy: 1902, gm: 4, gd: 9), + HijriYearRecord(hy: 1321, dpm: 0x0ea4, gy: 1903, gm: 3, gd: 30), + HijriYearRecord(hy: 1322, dpm: 0x0d4a, gy: 1904, gm: 3, gd: 18), + HijriYearRecord(hy: 1323, dpm: 0x0a96, gy: 1905, gm: 3, gd: 7), + HijriYearRecord(hy: 1324, dpm: 0x0536, gy: 1906, gm: 2, gd: 24), + HijriYearRecord(hy: 1325, dpm: 0x0ab5, gy: 1907, gm: 2, gd: 13), + HijriYearRecord(hy: 1326, dpm: 0x0daa, gy: 1908, gm: 2, gd: 3), + HijriYearRecord(hy: 1327, dpm: 0x0ba4, gy: 1909, gm: 1, gd: 23), + HijriYearRecord(hy: 1328, dpm: 0x0b49, gy: 1910, gm: 1, gd: 12), + HijriYearRecord(hy: 1329, dpm: 0x0a93, gy: 1911, gm: 1, gd: 1), + HijriYearRecord(hy: 1330, dpm: 0x052b, gy: 1911, gm: 12, gd: 21), + HijriYearRecord(hy: 1331, dpm: 0x0a57, gy: 1912, gm: 12, gd: 9), + HijriYearRecord(hy: 1332, dpm: 0x04b6, gy: 1913, gm: 11, gd: 29), + HijriYearRecord(hy: 1333, dpm: 0x0ab5, gy: 1914, gm: 11, gd: 18), + HijriYearRecord(hy: 1334, dpm: 0x05aa, gy: 1915, gm: 11, gd: 8), + HijriYearRecord(hy: 1335, dpm: 0x0d55, gy: 1916, gm: 10, gd: 27), + HijriYearRecord(hy: 1336, dpm: 0x0d2a, gy: 1917, gm: 10, gd: 17), + HijriYearRecord(hy: 1337, dpm: 0x0a56, gy: 1918, gm: 10, gd: 6), + HijriYearRecord(hy: 1338, dpm: 0x04ae, gy: 1919, gm: 9, gd: 25), + HijriYearRecord(hy: 1339, dpm: 0x095d, gy: 1920, gm: 9, gd: 13), + HijriYearRecord(hy: 1340, dpm: 0x02ec, gy: 1921, gm: 9, gd: 3), + HijriYearRecord(hy: 1341, dpm: 0x06d5, gy: 1922, gm: 8, gd: 23), + HijriYearRecord(hy: 1342, dpm: 0x06aa, gy: 1923, gm: 8, gd: 13), + HijriYearRecord(hy: 1343, dpm: 0x0555, gy: 1924, gm: 8, gd: 1), + HijriYearRecord(hy: 1344, dpm: 0x04ab, gy: 1925, gm: 7, gd: 21), + HijriYearRecord(hy: 1345, dpm: 0x095b, gy: 1926, gm: 7, gd: 10), + HijriYearRecord(hy: 1346, dpm: 0x02ba, gy: 1927, gm: 6, gd: 30), + HijriYearRecord(hy: 1347, dpm: 0x0575, gy: 1928, gm: 6, gd: 18), + HijriYearRecord(hy: 1348, dpm: 0x0bb2, gy: 1929, gm: 6, gd: 8), + HijriYearRecord(hy: 1349, dpm: 0x0764, gy: 1930, gm: 5, gd: 29), + HijriYearRecord(hy: 1350, dpm: 0x0749, gy: 1931, gm: 5, gd: 18), + HijriYearRecord(hy: 1351, dpm: 0x0655, gy: 1932, gm: 5, gd: 6), + HijriYearRecord(hy: 1352, dpm: 0x02ab, gy: 1933, gm: 4, gd: 25), + HijriYearRecord(hy: 1353, dpm: 0x055b, gy: 1934, gm: 4, gd: 14), + HijriYearRecord(hy: 1354, dpm: 0x0ada, gy: 1935, gm: 4, gd: 4), + HijriYearRecord(hy: 1355, dpm: 0x06d4, gy: 1936, gm: 3, gd: 24), + HijriYearRecord(hy: 1356, dpm: 0x0ec9, gy: 1937, gm: 3, gd: 13), + HijriYearRecord(hy: 1357, dpm: 0x0d92, gy: 1938, gm: 3, gd: 3), + HijriYearRecord(hy: 1358, dpm: 0x0d25, gy: 1939, gm: 2, gd: 20), + HijriYearRecord(hy: 1359, dpm: 0x0a4d, gy: 1940, gm: 2, gd: 9), + HijriYearRecord(hy: 1360, dpm: 0x02ad, gy: 1941, gm: 1, gd: 28), + HijriYearRecord(hy: 1361, dpm: 0x056d, gy: 1942, gm: 1, gd: 17), + HijriYearRecord(hy: 1362, dpm: 0x0b6a, gy: 1943, gm: 1, gd: 7), + HijriYearRecord(hy: 1363, dpm: 0x0b52, gy: 1943, gm: 12, gd: 28), + HijriYearRecord(hy: 1364, dpm: 0x0aa5, gy: 1944, gm: 12, gd: 16), + HijriYearRecord(hy: 1365, dpm: 0x0a4b, gy: 1945, gm: 12, gd: 5), + HijriYearRecord(hy: 1366, dpm: 0x0497, gy: 1946, gm: 11, gd: 24), + HijriYearRecord(hy: 1367, dpm: 0x0937, gy: 1947, gm: 11, gd: 13), + HijriYearRecord(hy: 1368, dpm: 0x02b6, gy: 1948, gm: 11, gd: 2), + HijriYearRecord(hy: 1369, dpm: 0x0575, gy: 1949, gm: 10, gd: 22), + HijriYearRecord(hy: 1370, dpm: 0x0d6a, gy: 1950, gm: 10, gd: 12), + HijriYearRecord(hy: 1371, dpm: 0x0d52, gy: 1951, gm: 10, gd: 2), + HijriYearRecord(hy: 1372, dpm: 0x0a96, gy: 1952, gm: 9, gd: 20), + HijriYearRecord(hy: 1373, dpm: 0x092d, gy: 1953, gm: 9, gd: 9), + HijriYearRecord(hy: 1374, dpm: 0x025d, gy: 1954, gm: 8, gd: 29), + HijriYearRecord(hy: 1375, dpm: 0x04dd, gy: 1955, gm: 8, gd: 18), + HijriYearRecord(hy: 1376, dpm: 0x0ada, gy: 1956, gm: 8, gd: 7), + HijriYearRecord(hy: 1377, dpm: 0x05d4, gy: 1957, gm: 7, gd: 28), + HijriYearRecord(hy: 1378, dpm: 0x0da9, gy: 1958, gm: 7, gd: 17), + HijriYearRecord(hy: 1379, dpm: 0x0d52, gy: 1959, gm: 7, gd: 7), + HijriYearRecord(hy: 1380, dpm: 0x0aaa, gy: 1960, gm: 6, gd: 25), + HijriYearRecord(hy: 1381, dpm: 0x04d6, gy: 1961, gm: 6, gd: 14), + HijriYearRecord(hy: 1382, dpm: 0x09b6, gy: 1962, gm: 6, gd: 3), + HijriYearRecord(hy: 1383, dpm: 0x0374, gy: 1963, gm: 5, gd: 24), + HijriYearRecord(hy: 1384, dpm: 0x0769, gy: 1964, gm: 5, gd: 12), + HijriYearRecord(hy: 1385, dpm: 0x0752, gy: 1965, gm: 5, gd: 2), + HijriYearRecord(hy: 1386, dpm: 0x06a5, gy: 1966, gm: 4, gd: 21), + HijriYearRecord(hy: 1387, dpm: 0x054b, gy: 1967, gm: 4, gd: 10), + HijriYearRecord(hy: 1388, dpm: 0x0aab, gy: 1968, gm: 3, gd: 29), + HijriYearRecord(hy: 1389, dpm: 0x055a, gy: 1969, gm: 3, gd: 19), + HijriYearRecord(hy: 1390, dpm: 0x0ad5, gy: 1970, gm: 3, gd: 8), + HijriYearRecord(hy: 1391, dpm: 0x0dd2, gy: 1971, gm: 2, gd: 26), + HijriYearRecord(hy: 1392, dpm: 0x0da4, gy: 1972, gm: 2, gd: 16), + HijriYearRecord(hy: 1393, dpm: 0x0d49, gy: 1973, gm: 2, gd: 4), + HijriYearRecord(hy: 1394, dpm: 0x0a95, gy: 1974, gm: 1, gd: 24), + HijriYearRecord(hy: 1395, dpm: 0x052d, gy: 1975, gm: 1, gd: 13), + HijriYearRecord(hy: 1396, dpm: 0x0a5d, gy: 1976, gm: 1, gd: 2), + HijriYearRecord(hy: 1397, dpm: 0x055a, gy: 1976, gm: 12, gd: 22), + HijriYearRecord(hy: 1398, dpm: 0x0ad5, gy: 1977, gm: 12, gd: 11), + HijriYearRecord(hy: 1399, dpm: 0x06aa, gy: 1978, gm: 12, gd: 1), + HijriYearRecord(hy: 1400, dpm: 0x0695, gy: 1979, gm: 11, gd: 20), + HijriYearRecord(hy: 1401, dpm: 0x052b, gy: 1980, gm: 11, gd: 8), + HijriYearRecord(hy: 1402, dpm: 0x0a57, gy: 1981, gm: 10, gd: 28), + HijriYearRecord(hy: 1403, dpm: 0x04ae, gy: 1982, gm: 10, gd: 18), + HijriYearRecord(hy: 1404, dpm: 0x0976, gy: 1983, gm: 10, gd: 7), + HijriYearRecord(hy: 1405, dpm: 0x056c, gy: 1984, gm: 9, gd: 26), + HijriYearRecord(hy: 1406, dpm: 0x0b55, gy: 1985, gm: 9, gd: 15), + HijriYearRecord(hy: 1407, dpm: 0x0aaa, gy: 1986, gm: 9, gd: 5), + HijriYearRecord(hy: 1408, dpm: 0x0a55, gy: 1987, gm: 8, gd: 25), + HijriYearRecord(hy: 1409, dpm: 0x04ad, gy: 1988, gm: 8, gd: 13), + HijriYearRecord(hy: 1410, dpm: 0x095d, gy: 1989, gm: 8, gd: 2), + HijriYearRecord(hy: 1411, dpm: 0x02da, gy: 1990, gm: 7, gd: 23), + HijriYearRecord(hy: 1412, dpm: 0x05d9, gy: 1991, gm: 7, gd: 12), + HijriYearRecord(hy: 1413, dpm: 0x0db2, gy: 1992, gm: 7, gd: 1), + HijriYearRecord(hy: 1414, dpm: 0x0ba4, gy: 1993, gm: 6, gd: 21), + HijriYearRecord(hy: 1415, dpm: 0x0b4a, gy: 1994, gm: 6, gd: 10), + HijriYearRecord(hy: 1416, dpm: 0x0a55, gy: 1995, gm: 5, gd: 30), + HijriYearRecord(hy: 1417, dpm: 0x02b5, gy: 1996, gm: 5, gd: 18), + HijriYearRecord(hy: 1418, dpm: 0x0575, gy: 1997, gm: 5, gd: 7), + HijriYearRecord(hy: 1419, dpm: 0x0b6a, gy: 1998, gm: 4, gd: 27), + HijriYearRecord(hy: 1420, dpm: 0x0bd2, gy: 1999, gm: 4, gd: 17), + HijriYearRecord(hy: 1421, dpm: 0x0bc4, gy: 2000, gm: 4, gd: 6), + HijriYearRecord(hy: 1422, dpm: 0x0b89, gy: 2001, gm: 3, gd: 26), + HijriYearRecord(hy: 1423, dpm: 0x0a95, gy: 2002, gm: 3, gd: 15), + HijriYearRecord(hy: 1424, dpm: 0x052d, gy: 2003, gm: 3, gd: 4), + HijriYearRecord(hy: 1425, dpm: 0x05ad, gy: 2004, gm: 2, gd: 21), + HijriYearRecord(hy: 1426, dpm: 0x0b6a, gy: 2005, gm: 2, gd: 10), + HijriYearRecord(hy: 1427, dpm: 0x06d4, gy: 2006, gm: 1, gd: 31), + HijriYearRecord(hy: 1428, dpm: 0x0dc9, gy: 2007, gm: 1, gd: 20), + HijriYearRecord(hy: 1429, dpm: 0x0d92, gy: 2008, gm: 1, gd: 10), + HijriYearRecord(hy: 1430, dpm: 0x0aa6, gy: 2008, gm: 12, gd: 29), + HijriYearRecord(hy: 1431, dpm: 0x0956, gy: 2009, gm: 12, gd: 18), + HijriYearRecord(hy: 1432, dpm: 0x02ae, gy: 2010, gm: 12, gd: 7), + HijriYearRecord(hy: 1433, dpm: 0x056d, gy: 2011, gm: 11, gd: 26), + HijriYearRecord(hy: 1434, dpm: 0x036a, gy: 2012, gm: 11, gd: 15), + HijriYearRecord(hy: 1435, dpm: 0x0b55, gy: 2013, gm: 11, gd: 4), + HijriYearRecord(hy: 1436, dpm: 0x0aaa, gy: 2014, gm: 10, gd: 25), + HijriYearRecord(hy: 1437, dpm: 0x094d, gy: 2015, gm: 10, gd: 14), + HijriYearRecord(hy: 1438, dpm: 0x049d, gy: 2016, gm: 10, gd: 2), + HijriYearRecord(hy: 1439, dpm: 0x095d, gy: 2017, gm: 9, gd: 21), + HijriYearRecord(hy: 1440, dpm: 0x02ba, gy: 2018, gm: 9, gd: 11), + HijriYearRecord(hy: 1441, dpm: 0x05b5, gy: 2019, gm: 8, gd: 31), + HijriYearRecord(hy: 1442, dpm: 0x05aa, gy: 2020, gm: 8, gd: 20), + HijriYearRecord(hy: 1443, dpm: 0x0d55, gy: 2021, gm: 8, gd: 9), + HijriYearRecord(hy: 1444, dpm: 0x0a9a, gy: 2022, gm: 7, gd: 30), + HijriYearRecord(hy: 1445, dpm: 0x092e, gy: 2023, gm: 7, gd: 19), + HijriYearRecord(hy: 1446, dpm: 0x026e, gy: 2024, gm: 7, gd: 7), + HijriYearRecord(hy: 1447, dpm: 0x055d, gy: 2025, gm: 6, gd: 26), + HijriYearRecord(hy: 1448, dpm: 0x0ada, gy: 2026, gm: 6, gd: 16), + HijriYearRecord(hy: 1449, dpm: 0x06d4, gy: 2027, gm: 6, gd: 6), + HijriYearRecord(hy: 1450, dpm: 0x06a5, gy: 2028, gm: 5, gd: 25), + HijriYearRecord(hy: 1451, dpm: 0x054b, gy: 2029, gm: 5, gd: 14), + HijriYearRecord(hy: 1452, dpm: 0x0a97, gy: 2030, gm: 5, gd: 3), + HijriYearRecord(hy: 1453, dpm: 0x054e, gy: 2031, gm: 4, gd: 23), + HijriYearRecord(hy: 1454, dpm: 0x0aae, gy: 2032, gm: 4, gd: 11), + HijriYearRecord(hy: 1455, dpm: 0x05ac, gy: 2033, gm: 4, gd: 1), + HijriYearRecord(hy: 1456, dpm: 0x0ba9, gy: 2034, gm: 3, gd: 21), + HijriYearRecord(hy: 1457, dpm: 0x0d92, gy: 2035, gm: 3, gd: 11), + HijriYearRecord(hy: 1458, dpm: 0x0b25, gy: 2036, gm: 2, gd: 28), + HijriYearRecord(hy: 1459, dpm: 0x064b, gy: 2037, gm: 2, gd: 16), + HijriYearRecord(hy: 1460, dpm: 0x0cab, gy: 2038, gm: 2, gd: 5), + HijriYearRecord(hy: 1461, dpm: 0x055a, gy: 2039, gm: 1, gd: 26), + HijriYearRecord(hy: 1462, dpm: 0x0b55, gy: 2040, gm: 1, gd: 15), + HijriYearRecord(hy: 1463, dpm: 0x06d2, gy: 2041, gm: 1, gd: 4), + HijriYearRecord(hy: 1464, dpm: 0x0ea5, gy: 2041, gm: 12, gd: 24), + HijriYearRecord(hy: 1465, dpm: 0x0e4a, gy: 2042, gm: 12, gd: 14), + HijriYearRecord(hy: 1466, dpm: 0x0a95, gy: 2043, gm: 12, gd: 3), + HijriYearRecord(hy: 1467, dpm: 0x052d, gy: 2044, gm: 11, gd: 21), + HijriYearRecord(hy: 1468, dpm: 0x0aad, gy: 2045, gm: 11, gd: 10), + HijriYearRecord(hy: 1469, dpm: 0x036c, gy: 2046, gm: 10, gd: 31), + HijriYearRecord(hy: 1470, dpm: 0x0759, gy: 2047, gm: 10, gd: 20), + HijriYearRecord(hy: 1471, dpm: 0x06d2, gy: 2048, gm: 10, gd: 9), + HijriYearRecord(hy: 1472, dpm: 0x0695, gy: 2049, gm: 9, gd: 28), + HijriYearRecord(hy: 1473, dpm: 0x052d, gy: 2050, gm: 9, gd: 17), + HijriYearRecord(hy: 1474, dpm: 0x0a5b, gy: 2051, gm: 9, gd: 6), + HijriYearRecord(hy: 1475, dpm: 0x04ba, gy: 2052, gm: 8, gd: 26), + HijriYearRecord(hy: 1476, dpm: 0x09ba, gy: 2053, gm: 8, gd: 15), + HijriYearRecord(hy: 1477, dpm: 0x03b4, gy: 2054, gm: 8, gd: 5), + HijriYearRecord(hy: 1478, dpm: 0x0b69, gy: 2055, gm: 7, gd: 25), + HijriYearRecord(hy: 1479, dpm: 0x0b52, gy: 2056, gm: 7, gd: 14), + HijriYearRecord(hy: 1480, dpm: 0x0aa6, gy: 2057, gm: 7, gd: 3), + HijriYearRecord(hy: 1481, dpm: 0x04b6, gy: 2058, gm: 6, gd: 22), + HijriYearRecord(hy: 1482, dpm: 0x096d, gy: 2059, gm: 6, gd: 11), + HijriYearRecord(hy: 1483, dpm: 0x02ec, gy: 2060, gm: 5, gd: 31), + HijriYearRecord(hy: 1484, dpm: 0x06d9, gy: 2061, gm: 5, gd: 20), + HijriYearRecord(hy: 1485, dpm: 0x0eb2, gy: 2062, gm: 5, gd: 10), + HijriYearRecord(hy: 1486, dpm: 0x0d54, gy: 2063, gm: 4, gd: 30), + HijriYearRecord(hy: 1487, dpm: 0x0d2a, gy: 2064, gm: 4, gd: 18), + HijriYearRecord(hy: 1488, dpm: 0x0a56, gy: 2065, gm: 4, gd: 7), + HijriYearRecord(hy: 1489, dpm: 0x04ae, gy: 2066, gm: 3, gd: 27), + HijriYearRecord(hy: 1490, dpm: 0x096d, gy: 2067, gm: 3, gd: 16), + HijriYearRecord(hy: 1491, dpm: 0x0d6a, gy: 2068, gm: 3, gd: 5), + HijriYearRecord(hy: 1492, dpm: 0x0b54, gy: 2069, gm: 2, gd: 23), + HijriYearRecord(hy: 1493, dpm: 0x0b29, gy: 2070, gm: 2, gd: 12), + HijriYearRecord(hy: 1494, dpm: 0x0a93, gy: 2071, gm: 2, gd: 1), + HijriYearRecord(hy: 1495, dpm: 0x052b, gy: 2072, gm: 1, gd: 21), + HijriYearRecord(hy: 1496, dpm: 0x0a57, gy: 2073, gm: 1, gd: 9), + HijriYearRecord(hy: 1497, dpm: 0x0536, gy: 2073, gm: 12, gd: 30), + HijriYearRecord(hy: 1498, dpm: 0x0ab5, gy: 2074, gm: 12, gd: 19), + HijriYearRecord(hy: 1499, dpm: 0x06aa, gy: 2075, gm: 12, gd: 9), + HijriYearRecord(hy: 1500, dpm: 0x0e93, gy: 2076, gm: 11, gd: 27), + HijriYearRecord(hy: 1501, dpm: 0, gy: 2077, gm: 11, gd: 17), +]; diff --git a/lib/src/engines/fcna.dart b/lib/src/engines/fcna.dart new file mode 100644 index 0000000..9956336 --- /dev/null +++ b/lib/src/engines/fcna.dart @@ -0,0 +1,313 @@ +// FCNA engine: Fiqh Council of North America / ISNA calendar. +// +// The FCNA criterion: if the new moon conjunction occurs before 12:00 noon UTC +// on day D, the new Hijri month begins at midnight starting day D+1. If at or +// after 12:00 UTC, the month begins at midnight starting day D+2. +// +// New moon times come from Jean Meeus, Astronomical Algorithms (2nd ed.), +// Chapter 49, accurate to within a few minutes for 1000-3000 CE. + +import 'dart:math' as math; + +import '../constants.dart'; +import '../data.dart'; +import '../types.dart'; + +// ---- Constants ---- + +const double _synodic = 29.530588861; // Mean synodic month (days) +const double _jde0 = + 2451550.09766; // Meeus k=0 (2nd ed. Ch.49: 2451550.09765; 0.864 s diff) +const double _jdeUnix = 2440587.5; // JDE of Unix epoch 1970-01-01 00:00 UTC +const double _toRad = math.pi / 180; + +// Approximate k index of 1 Muharram 1 AH in Meeus numbering. +// Islamic epoch JDE ~1948438.5 -> k ~= (1948438.5 - _jde0) / _synodic ~= -17037. +const int _kEpoch = -17037; + +// ---- Meeus Chapter 49: corrected new moon JDE ---- + +double _newMoonJDE(double k) { + final t = k / 1236.85; + final t2 = t * t; + final t3 = t2 * t; + final t4 = t3 * t; + + double jde = + _jde0 + + _synodic * k + + 0.00015437 * t2 - + 0.00000015 * t3 + + 0.00000000073 * t4; + + final m = (2.5534 + 29.1053567 * k - 0.0000014 * t2 - 0.00000011 * t3) % 360; + + final mprime = + (201.5643 + + 385.81693528 * k + + 0.0107582 * t2 + + 0.00001238 * t3 - + 0.000000058 * t4) % + 360; + + final f = + (160.7108 + + 390.67050284 * k - + 0.0016118 * t2 - + 0.00000227 * t3 + + 0.000000011 * t4) % + 360; + + final omega = + (124.7746 - 1.56375588 * k + 0.0020672 * t2 + 0.00000215 * t3) % 360; + + final e = 1 - 0.002516 * t - 0.0000074 * t2; + final e2 = e * e; + + final mRad = m * _toRad; + final mpRad = mprime * _toRad; + final fRad = f * _toRad; + final oRad = omega * _toRad; + + jde += + -0.4072 * math.sin(mpRad) + + 0.17241 * e * math.sin(mRad) + + 0.01608 * math.sin(2 * mpRad) + + 0.01039 * math.sin(2 * fRad) + + 0.00739 * e * math.sin(mpRad - mRad) - + 0.00514 * e * math.sin(mpRad + mRad) + + 0.00208 * e2 * math.sin(2 * mRad) - + 0.00111 * math.sin(mpRad - 2 * fRad) - + 0.00057 * math.sin(mpRad + 2 * fRad) + + 0.00056 * e * math.sin(2 * mpRad + mRad) - + 0.00042 * math.sin(3 * mpRad) + + 0.00042 * e * math.sin(mRad + 2 * fRad) + + 0.00038 * e * math.sin(mRad - 2 * fRad) - + 0.00024 * e * math.sin(2 * mpRad - mRad) - + 0.00017 * math.sin(oRad) - + 0.00007 * math.sin(mpRad + 2 * mRad) + + 0.00004 * math.sin(2 * mpRad - 2 * fRad) + + 0.00004 * math.sin(3 * mRad) + + 0.00003 * math.sin(mpRad + mRad - 2 * fRad) + + 0.00003 * math.sin(2 * mpRad + 2 * fRad) - + 0.00003 * math.sin(mpRad + mRad + 2 * fRad) + + 0.00003 * math.sin(mpRad - mRad + 2 * fRad) - + 0.00002 * math.sin(mpRad - mRad - 2 * fRad) - + 0.00002 * math.sin(3 * mpRad + mRad) + + 0.00002 * math.sin(4 * mpRad); + + final a1 = (299.77 + 0.107408 * k - 0.009173 * t2) * _toRad; + final a2 = (251.88 + 0.016321 * k) * _toRad; + final a3 = (251.83 + 26.651886 * k) * _toRad; + final a4 = (349.42 + 36.412478 * k) * _toRad; + final a5 = (84.66 + 18.206239 * k) * _toRad; + final a6 = (141.74 + 53.303771 * k) * _toRad; + final a7 = (207.14 + 2.453732 * k) * _toRad; + final a8 = (154.84 + 7.30686 * k) * _toRad; + final a9 = (34.52 + 27.261239 * k) * _toRad; + final a10 = (207.19 + 0.121824 * k) * _toRad; + final a11 = (291.34 + 1.844379 * k) * _toRad; + final a12 = (161.72 + 24.198154 * k) * _toRad; + final a13 = (239.56 + 25.513099 * k) * _toRad; + final a14 = (331.55 + 3.592518 * k) * _toRad; + + jde += + 0.000325 * math.sin(a1) + + 0.000165 * math.sin(a2) + + 0.000164 * math.sin(a3) + + 0.000126 * math.sin(a4) + + 0.00011 * math.sin(a5) + + 0.000062 * math.sin(a6) + + 0.00006 * math.sin(a7) + + 0.000056 * math.sin(a8) + + 0.000047 * math.sin(a9) + + 0.000042 * math.sin(a10) + + 0.00004 * math.sin(a11) + + 0.000037 * math.sin(a12) + + 0.000035 * math.sin(a13) + + 0.000023 * math.sin(a14); + + return jde; +} + +// ---- JDE / UTC conversion ---- + +double _jdeToUtcMs(double jde) { + return (jde - _jdeUnix) * msPerDay; +} + +double _utcMsToKApprox(double ms) { + final jde = ms / msPerDay + _jdeUnix; + return (jde - _jde0) / _synodic; +} + +// ---- Find nearest corrected new moon ---- + +// Searches k0-2 through k0+2 to handle any estimation error. +double _nearestNewMoonMs(double anchorMs) { + final k0 = _utcMsToKApprox(anchorMs).round(); + double bestMs = 0; + double bestDist = double.infinity; + + for (int k = k0 - 2; k <= k0 + 2; k++) { + final ms = _jdeToUtcMs(_newMoonJDE(k.toDouble())); + final dist = (ms - anchorMs).abs(); + if (dist < bestDist) { + bestDist = dist; + bestMs = ms; + } + } + + return bestMs; +} + +// ---- FCNA criterion ---- + +// Returns the midnight UTC ms that starts the new FCNA Hijri month. +double _fcnaCriterionMs(double conjMs) { + final midnight = (conjMs / msPerDay).floor() * msPerDay; + final noon = midnight + 12 * 3600000; + return (conjMs < noon ? midnight + msPerDay : midnight + 2 * msPerDay) + .toDouble(); +} + +// ---- UAQ anchor ---- + +// Returns the UTC ms of the UAQ month start for (hy, hm). +// In-range years (1318-1500 H): binary-search table, sum dpm day counts. +// Out-of-range years: estimate from Islamic epoch + mean synodic month count. +double _uaqAnchorMs(int hy, int hm) { + int lo = 0; + int hi = hDatesTable.length - 1; + int found = -1; + + while (lo <= hi) { + final mid = (lo + hi) >>> 1; + final midHy = hDatesTable[mid].hy; + if (midHy == hy) { + found = mid; + break; + } else if (midHy < hy) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + if (found != -1 && hDatesTable[found].dpm != 0) { + final r = hDatesTable[found]; + int days = 0; + for (int i = 0; i < hm - 1; i++) { + days += (r.dpm >> i) & 1 == 1 ? 30 : 29; + } + return (DateTime.utc(r.gy, r.gm, r.gd).millisecondsSinceEpoch + + days * msPerDay) + .toDouble(); + } + + final monthsFromEpoch = (hy - 1) * monthsPerYear + (hm - 1); + final kApprox = _kEpoch + monthsFromEpoch; + return _jdeToUtcMs(_newMoonJDE(kApprox.toDouble())); +} + +// ---- FCNA month start ---- + +double _fcnaMonthStartMs(int hy, int hm) { + final anchor = _uaqAnchorMs(hy, hm); + final conjMs = _nearestNewMoonMs(anchor); + return _fcnaCriterionMs(conjMs); +} + +// ---- FCNA month length ---- + +int _fcnaDaysInMonth(int hy, int hm) { + if (hm < 1 || hm > monthsPerYear) { + throw RangeError('month must be 1-12, got $hm'); + } + final thisStart = _fcnaMonthStartMs(hy, hm); + final nextHy = hm < monthsPerYear ? hy : hy + 1; + final nextHm = hm < monthsPerYear ? hm + 1 : 1; + final nextStart = _fcnaMonthStartMs(nextHy, nextHm); + return ((nextStart - thisStart) / msPerDay).round(); +} + +// ---- FCNA Gregorian -> Hijri ---- + +HijriDate? _fcnaToHijri(DateTime date) { + // FCNA criterion is UTC-based, so UTC date components ensure correct round-trips. + final inputMs = + DateTime.utc( + date.year, + date.month, + date.day, + ).millisecondsSinceEpoch.toDouble(); + + final kApprox = _utcMsToKApprox(inputMs - 15 * msPerDay); + final k0 = kApprox.floor(); + + for (int ki = k0 - 1; ki <= k0 + 1; ki++) { + final conjMs = _jdeToUtcMs(_newMoonJDE(ki.toDouble())); + final monthStart = _fcnaCriterionMs(conjMs); + + if (monthStart > inputMs) continue; + + final nextConjMs = _jdeToUtcMs(_newMoonJDE((ki + 1).toDouble())); + final nextMonthStart = _fcnaCriterionMs(nextConjMs); + + if (inputMs < nextMonthStart) { + final monthsFromEpoch = ki - _kEpoch; + int hy = monthsFromEpoch ~/ monthsPerYear + 1; + int hm = (monthsFromEpoch % monthsPerYear) + 1; + if (hm <= 0) { + hm += monthsPerYear; + hy--; + } + if (hy < 1) return null; + + final hd = ((inputMs - monthStart) / msPerDay).round() + 1; + return HijriDate(hy: hy, hm: hm, hd: hd); + } + } + + return null; +} + +// ---- FCNA Hijri -> Gregorian ---- + +DateTime? _fcnaToGregorian(int hy, int hm, int hd) { + if (hy < 1 || hm < 1 || hm > monthsPerYear || hd < 1) return null; + final days = _fcnaDaysInMonth(hy, hm); + if (hd > days) return null; + final startMs = _fcnaMonthStartMs(hy, hm); + return DateTime.fromMillisecondsSinceEpoch( + (startMs + (hd - 1) * msPerDay).round(), + isUtc: true, + ); +} + +// ---- FCNA validation ---- + +bool _fcnaIsValid(int hy, int hm, int hd) { + if (hy < 1 || hm < 1 || hm > monthsPerYear || hd < 1) return false; + return hd <= _fcnaDaysInMonth(hy, hm); +} + +/// The FCNA (Fiqh Council of North America) calendar engine. +final CalendarEngine fcnaEngine = _FcnaEngine(); + +class _FcnaEngine extends CalendarEngine { + @override + String get id => 'fcna'; + + @override + HijriDate? toHijri(DateTime date) => _fcnaToHijri(date); + + @override + DateTime? toGregorian(int hy, int hm, int hd) => _fcnaToGregorian(hy, hm, hd); + + @override + bool isValid(int hy, int hm, int hd) => _fcnaIsValid(hy, hm, hd); + + @override + int daysInMonth(int hy, int hm) => _fcnaDaysInMonth(hy, hm); +} diff --git a/lib/src/engines/uaq.dart b/lib/src/engines/uaq.dart new file mode 100644 index 0000000..1fe7a74 --- /dev/null +++ b/lib/src/engines/uaq.dart @@ -0,0 +1,141 @@ +// UAQ engine: Umm al-Qura calendar (official Saudi Arabian Islamic calendar). +// +// Conversions are table-driven. The reference table covers Hijri years 1318-1500 +// (Gregorian 1900-2076). Each entry records the Gregorian date of 1 Muharram and +// a 12-bit days-per-month bitmask. Dates outside that window return null. + +import '../constants.dart'; +import '../data.dart'; +import '../types.dart'; + +/// Binary search for a Hijri year entry in the UAQ table. +/// +/// Returns the entry whose [hy] matches exactly, or null if the year is not +/// present in the table. The table covers Hijri years 1318 through 1501 +/// (the final entry is a sentinel with dpm == 0). +HijriYearRecord? _findYearEntry(int hy) { + int lo = 0; + int hi = hDatesTable.length - 1; + + while (lo <= hi) { + final mid = (lo + hi) >>> 1; + final midHy = hDatesTable[mid].hy; + if (midHy == hy) return hDatesTable[mid]; + if (midHy < hy) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return null; +} + +/// Compute UTC milliseconds since epoch for a Gregorian date. +int _dateUtcMs(int year, int month, int day) { + return DateTime.utc(year, month, day).millisecondsSinceEpoch; +} + +HijriDate? _uaqToHijri(DateTime date) { + final inputUtc = _dateUtcMs(date.year, date.month, date.day); + + // Binary search: find the last table entry whose Gregorian start date <= input. + int lo = 0; + int hi = hDatesTable.length - 1; + int found = -1; + + while (lo <= hi) { + final mid = (lo + hi) >>> 1; + final entry = hDatesTable[mid]; + final entryUtc = _dateUtcMs(entry.gy, entry.gm, entry.gd); + + if (entryUtc <= inputUtc) { + found = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + // dpm == 0 is the sentinel entry (hy 1501) marking the upper bound. + if (found == -1 || hDatesTable[found].dpm == 0) return null; + + final record = hDatesTable[found]; + final startUtc = _dateUtcMs(record.gy, record.gm, record.gd); + int remaining = ((inputUtc - startUtc) / msPerDay).round(); + int hijriMonth = 0; + + for (int i = 0; i < monthsPerYear; i++) { + final dim = (record.dpm >> i) & 1 == 1 ? 30 : 29; + if (remaining < dim) { + hijriMonth = i + 1; + break; + } + remaining -= dim; + } + + if (hijriMonth == 0) return null; + + return HijriDate(hy: record.hy, hm: hijriMonth, hd: remaining + 1); +} + +DateTime? _uaqToGregorian(int hy, int hm, int hd) { + if (!_uaqIsValid(hy, hm, hd)) return null; + + final record = _findYearEntry(hy); + if (record == null) return null; + + int totalDays = 0; + for (int i = 0; i < hm - 1; i++) { + totalDays += (record.dpm >> i) & 1 == 1 ? 30 : 29; + } + totalDays += hd - 1; + + return DateTime.fromMillisecondsSinceEpoch( + _dateUtcMs(record.gy, record.gm, record.gd) + totalDays * msPerDay, + isUtc: true, + ); +} + +bool _uaqIsValid(int hy, int hm, int hd) { + if (hm < 1 || hm > monthsPerYear || hd < 1) return false; + + final record = _findYearEntry(hy); + if (record == null || record.dpm == 0) return false; + + final dim = (record.dpm >> (hm - 1)) & 1 == 1 ? 30 : 29; + return hd <= dim; +} + +int _uaqDaysInMonth(int hy, int hm) { + if (hm < 1 || hm > monthsPerYear) { + throw RangeError('month must be 1-12, got $hm'); + } + + final record = _findYearEntry(hy); + if (record == null || record.dpm == 0) { + throw RangeError( + 'Hijri year $hy is outside the UAQ table range (1318-1500).', + ); + } + return (record.dpm >> (hm - 1)) & 1 == 1 ? 30 : 29; +} + +/// The Umm al-Qura calendar engine. +final CalendarEngine uaqEngine = _UaqEngine(); + +class _UaqEngine extends CalendarEngine { + @override + String get id => 'uaq'; + + @override + HijriDate? toHijri(DateTime date) => _uaqToHijri(date); + + @override + DateTime? toGregorian(int hy, int hm, int hd) => _uaqToGregorian(hy, hm, hd); + + @override + bool isValid(int hy, int hm, int hd) => _uaqIsValid(hy, hm, hd); + + @override + int daysInMonth(int hy, int hm) => _uaqDaysInMonth(hy, hm); +} diff --git a/lib/src/hijri_core_api.dart b/lib/src/hijri_core_api.dart new file mode 100644 index 0000000..a0f4c48 --- /dev/null +++ b/lib/src/hijri_core_api.dart @@ -0,0 +1,51 @@ +import 'engines/fcna.dart'; +import 'engines/uaq.dart'; +import 'registry.dart'; +import 'types.dart'; + +// Register built-in engines at first access. +bool _registered = false; + +void _ensureRegistered() { + if (!_registered) { + registerCalendar('uaq', uaqEngine); + registerCalendar('fcna', fcnaEngine); + _registered = true; + } +} + +/// Convert a Gregorian [DateTime] to a Hijri date. +/// +/// Uses the UAQ (Umm al-Qura) calendar by default. Pass +/// `ConversionOptions(calendar: 'fcna')` or any registered calendar name +/// via [options] to use a different engine. +/// +/// Returns null if the date is out of range for the selected engine. +HijriDate? toHijri(DateTime date, {ConversionOptions? options}) { + _ensureRegistered(); + return getCalendar(options?.calendar ?? 'uaq').toHijri(date); +} + +/// Convert a Hijri date to a Gregorian [DateTime] (UTC). +/// +/// Uses the UAQ calendar by default. +/// +/// Returns null if the input is invalid or out of range. +DateTime? toGregorian(int hy, int hm, int hd, {ConversionOptions? options}) { + _ensureRegistered(); + return getCalendar(options?.calendar ?? 'uaq').toGregorian(hy, hm, hd); +} + +/// Check whether a Hijri date is valid for the given calendar engine. +bool isValidHijriDate(int hy, int hm, int hd, {ConversionOptions? options}) { + _ensureRegistered(); + return getCalendar(options?.calendar ?? 'uaq').isValid(hy, hm, hd); +} + +/// Return the number of days in a given Hijri month (29 or 30). +/// +/// Throws [RangeError] if the month or year is out of range. +int daysInHijriMonth(int hy, int hm, {ConversionOptions? options}) { + _ensureRegistered(); + return getCalendar(options?.calendar ?? 'uaq').daysInMonth(hy, hm); +} diff --git a/lib/src/names/months.dart b/lib/src/names/months.dart new file mode 100644 index 0000000..bc983b4 --- /dev/null +++ b/lib/src/names/months.dart @@ -0,0 +1,48 @@ +/// Hijri month names in long form. +/// Index 0 = Muharram (month 1), index 11 = Dhul Hijjah (month 12). +const List hmLong = [ + 'Muharram', // 1 + 'Safar', // 2 + "Rabi'l Awwal", // 3 + "Rabi'l Thani", // 4 + 'Jumadal Awwal', // 5 + 'Jumadal Thani', // 6 + 'Rajab', // 7 + "Sha'ban", // 8 + 'Ramadan', // 9 + 'Shawwal', // 10 + "Dhul Qi'dah", // 11 + 'Dhul Hijjah', // 12 +]; + +/// Hijri month names in medium form. +const List hmMedium = [ + 'Muharram', + 'Safar', + 'Rabi1', + 'Rabi2', + 'Jumada1', + 'Jumada2', + 'Rajab', + 'Shaban', + 'Ramadan', + 'Shawwal', + 'Dhul-Qidah', + 'Dhul-Hijjah', +]; + +/// Hijri month names in short form. +const List hmShort = [ + 'Muh', + 'Saf', + 'Ra1', + 'Ra2', + 'Ju1', + 'Ju2', + 'Raj', + 'Shb', + 'Ram', + 'Shw', + 'DhQ', + 'DhH', +]; diff --git a/lib/src/names/weekdays.dart b/lib/src/names/weekdays.dart new file mode 100644 index 0000000..fcce12e --- /dev/null +++ b/lib/src/names/weekdays.dart @@ -0,0 +1,25 @@ +/// Hijri weekday names in long form. +/// Index 0 = Sunday, index 6 = Saturday (matching DateTime.weekday % 7). +const List hwLong = [ + 'Yawm al-Ahad', // Sunday + 'Yawm al-Ithnayn', // Monday + "Yawm ath-Thulatha'", // Tuesday + "Yawm al-Arba`a'", // Wednesday + 'Yawm al-Khamis', // Thursday + 'Yawm al-Jum`a', // Friday + 'Yawm as-Sabt', // Saturday +]; + +/// Hijri weekday names in short form. +const List hwShort = [ + 'Ahad', // Sunday + 'Ithn', // Monday + 'Thul', // Tuesday + 'Arba', // Wednesday + 'Kham', // Thursday + 'Jum`a', // Friday + 'Sabt', // Saturday +]; + +/// Numeric weekday representation: 1 = Sunday, 7 = Saturday. +const List hwNumeric = [1, 2, 3, 4, 5, 6, 7]; diff --git a/lib/src/registry.dart b/lib/src/registry.dart new file mode 100644 index 0000000..8cdb524 --- /dev/null +++ b/lib/src/registry.dart @@ -0,0 +1,32 @@ +import 'types.dart'; + +final Map _engines = {}; + +/// Register a calendar engine under the given name. +/// +/// Once registered, the engine can be selected via [ConversionOptions.calendar] +/// in any conversion function or retrieved directly with [getCalendar]. +void registerCalendar(String name, CalendarEngine engine) { + _engines[name] = engine; +} + +/// Retrieve a registered calendar engine by name. +/// +/// Throws [StateError] if no engine is registered under that name. +CalendarEngine getCalendar(String name) { + final engine = _engines[name]; + if (engine == null) { + final available = listCalendars().join(', '); + throw StateError( + 'Unknown Hijri calendar: "$name". ' + 'Available: $available. ' + 'Register custom calendars with registerCalendar().', + ); + } + return engine; +} + +/// List the names of all registered calendar engines. +List listCalendars() { + return _engines.keys.toList(); +} diff --git a/lib/src/types.dart b/lib/src/types.dart new file mode 100644 index 0000000..a9d91a1 --- /dev/null +++ b/lib/src/types.dart @@ -0,0 +1,80 @@ +/// A Hijri calendar date with year, month, and day. +class HijriDate { + /// Hijri year. + final int hy; + + /// Hijri month (1-12). + final int hm; + + /// Hijri day (1-30). + final int hd; + + const HijriDate({required this.hy, required this.hm, required this.hd}); + + @override + bool operator ==(Object other) => + other is HijriDate && hy == other.hy && hm == other.hm && hd == other.hd; + + @override + int get hashCode => Object.hash(hy, hm, hd); + + @override + String toString() => 'HijriDate(hy: $hy, hm: $hm, hd: $hd)'; +} + +/// A row in the Umm al-Qura reference table. +class HijriYearRecord { + /// Hijri year. + final int hy; + + /// Days-per-month bitmask. Bit i (0-indexed) corresponds to month i+1: + /// 1 = 30 days, 0 = 29 days. + final int dpm; + + /// Gregorian year of 1 Muharram. + final int gy; + + /// Gregorian month of 1 Muharram (1-based). + final int gm; + + /// Gregorian day of 1 Muharram. + final int gd; + + const HijriYearRecord({ + required this.hy, + required this.dpm, + required this.gy, + required this.gm, + required this.gd, + }); +} + +/// Any calendar engine must implement this interface. +abstract class CalendarEngine { + /// Unique identifier for this engine (e.g. 'uaq', 'fcna'). + String get id; + + /// Convert a Gregorian [DateTime] (UTC) to a Hijri date. + /// Returns null for out-of-range input. + /// Throws [ArgumentError] for invalid input. + HijriDate? toHijri(DateTime date); + + /// Convert a Hijri date to a Gregorian [DateTime] (UTC). + /// Returns null for invalid or out-of-range input. + DateTime? toGregorian(int hy, int hm, int hd); + + /// Check whether a Hijri date is valid for this engine. + bool isValid(int hy, int hm, int hd); + + /// Return the number of days in a given Hijri month (29 or 30). + /// Throws [RangeError] if the month or year is out of range. + int daysInMonth(int hy, int hm); +} + +/// Options for selecting which calendar engine to use. +class ConversionOptions { + /// Calendar engine name. Defaults to 'uaq'. + final String? calendar; + + const ConversionOptions({this.calendar}); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..15a7ca4 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,20 @@ +name: hijri_core +description: > + Hijri/Gregorian calendar conversion for Dart and Flutter. Pluggable engine + system with built-in Umm al-Qura and FCNA calendars. Zero dependencies. +version: 1.0.0 +repository: https://github.com/acamarata/hijri-core-dart +issue_tracker: https://github.com/acamarata/hijri-core-dart/issues +topics: + - hijri + - islamic + - calendar + - gregorian + - umm-al-qura + +environment: + sdk: ^3.7.0 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.8 diff --git a/test/hijri_core_test.dart b/test/hijri_core_test.dart new file mode 100644 index 0000000..606a04c --- /dev/null +++ b/test/hijri_core_test.dart @@ -0,0 +1,351 @@ +import 'package:hijri_core/hijri_core.dart'; +import 'package:test/test.dart'; + +void main() { + // ---- Exports exist ---- + + group('exports', () { + test('toHijri is callable', () { + expect(toHijri, isA()); + }); + test('toGregorian is callable', () { + expect(toGregorian, isA()); + }); + test('isValidHijriDate is callable', () { + expect(isValidHijriDate, isA()); + }); + test('daysInHijriMonth is callable', () { + expect(daysInHijriMonth, isA()); + }); + test('registerCalendar is callable', () { + expect(registerCalendar, isA()); + }); + test('getCalendar is callable', () { + expect(getCalendar, isA()); + }); + test('listCalendars is callable', () { + expect(listCalendars, isA()); + }); + test('hDatesTable has > 180 entries', () { + expect(hDatesTable.length, greaterThan(180)); + }); + test('hmLong has 12 entries', () { + expect(hmLong.length, equals(12)); + }); + test('hmMedium has 12 entries', () { + expect(hmMedium.length, equals(12)); + }); + test('hmShort has 12 entries', () { + expect(hmShort.length, equals(12)); + }); + test('hwLong has 7 entries', () { + expect(hwLong.length, equals(7)); + }); + test('hwShort has 7 entries', () { + expect(hwShort.length, equals(7)); + }); + test('hwNumeric has 7 entries [1..7]', () { + expect(hwNumeric, equals([1, 2, 3, 4, 5, 6, 7])); + }); + }); + + // ---- UAQ toGregorian ---- + + group('UAQ toGregorian', () { + test('1444/9/1 = 2023-03-23', () { + final d = toGregorian(1444, 9, 1); + expect(d, isNotNull); + expect(d!.toIso8601String().substring(0, 10), equals('2023-03-23')); + }); + test('1446/9/1 = 2025-03-01', () { + final d = toGregorian(1446, 9, 1); + expect(d, isNotNull); + expect(d!.toIso8601String().substring(0, 10), equals('2025-03-01')); + }); + test('1446/10/1 = 2025-03-30', () { + final d = toGregorian(1446, 10, 1); + expect(d, isNotNull); + expect(d!.toIso8601String().substring(0, 10), equals('2025-03-30')); + }); + test('1318/1/1 = 1900-04-30', () { + final d = toGregorian(1318, 1, 1); + expect(d, isNotNull); + expect(d!.toIso8601String().substring(0, 10), equals('1900-04-30')); + }); + }); + + // ---- UAQ toHijri ---- + + group('UAQ toHijri', () { + test('2023-03-23 = 1444/9/1', () { + final h = toHijri(DateTime.utc(2023, 3, 23)); + expect(h, isNotNull); + expect(h!.hy, equals(1444)); + expect(h.hm, equals(9)); + expect(h.hd, equals(1)); + }); + test('2025-03-01 = 1446/9/1', () { + final h = toHijri(DateTime.utc(2025, 3, 1)); + expect(h, isNotNull); + expect(h!.hy, equals(1446)); + expect(h.hm, equals(9)); + expect(h.hd, equals(1)); + }); + }); + + // ---- UAQ isValid ---- + + group('UAQ isValid', () { + test('1444/9/1 = true', () { + expect(isValidHijriDate(1444, 9, 1), isTrue); + }); + test('1317/1/1 = false (before table)', () { + expect(isValidHijriDate(1317, 1, 1), isFalse); + }); + test('1501/1/1 = false (sentinel)', () { + expect(isValidHijriDate(1501, 1, 1), isFalse); + }); + test('month 0 = false', () { + expect(isValidHijriDate(1444, 0, 1), isFalse); + }); + test('month 13 = false', () { + expect(isValidHijriDate(1444, 13, 1), isFalse); + }); + }); + + // ---- UAQ daysInMonth ---- + + group('UAQ daysInMonth', () { + test('Ramadan 1444 = 29 days', () { + expect(daysInHijriMonth(1444, 9), equals(29)); + }); + test('throws for month 0', () { + expect(() => daysInHijriMonth(1444, 0), throwsRangeError); + }); + test('throws for month 13', () { + expect(() => daysInHijriMonth(1444, 13), throwsRangeError); + }); + }); + + // ---- FCNA toGregorian ---- + + group('FCNA toGregorian', () { + test('1446/9/1 = 2025-03-01', () { + final d = toGregorian( + 1446, + 9, + 1, + options: const ConversionOptions(calendar: 'fcna'), + ); + expect(d, isNotNull); + expect(d!.toIso8601String().substring(0, 10), equals('2025-03-01')); + }); + test('1446/10/1 = 2025-03-30', () { + final d = toGregorian( + 1446, + 10, + 1, + options: const ConversionOptions(calendar: 'fcna'), + ); + expect(d, isNotNull); + expect(d!.toIso8601String().substring(0, 10), equals('2025-03-30')); + }); + }); + + // ---- FCNA toHijri ---- + + group('FCNA toHijri', () { + test('2025-03-01 = 1446/9/1', () { + final h = toHijri( + DateTime.utc(2025, 3, 1), + options: const ConversionOptions(calendar: 'fcna'), + ); + expect(h, isNotNull); + expect(h!.hy, equals(1446)); + expect(h.hm, equals(9)); + expect(h.hd, equals(1)); + }); + }); + + // ---- FCNA round-trips ---- + + group('FCNA round-trips', () { + test('1446/9/1 toGregorian->toHijri', () { + final greg = toGregorian( + 1446, + 9, + 1, + options: const ConversionOptions(calendar: 'fcna'), + ); + expect(greg, isNotNull); + final hijri = toHijri( + greg!, + options: const ConversionOptions(calendar: 'fcna'), + ); + expect(hijri, isNotNull); + expect(hijri!.hy, equals(1446)); + expect(hijri.hm, equals(9)); + expect(hijri.hd, equals(1)); + }); + test('1446/10/15 toGregorian->toHijri', () { + final greg = toGregorian( + 1446, + 10, + 15, + options: const ConversionOptions(calendar: 'fcna'), + ); + expect(greg, isNotNull); + final hijri = toHijri( + greg!, + options: const ConversionOptions(calendar: 'fcna'), + ); + expect(hijri, isNotNull); + expect(hijri!.hy, equals(1446)); + expect(hijri.hm, equals(10)); + expect(hijri.hd, equals(15)); + }); + }); + + // ---- FCNA isValid ---- + + group('FCNA isValid', () { + test('1/1/1 = true', () { + expect( + isValidHijriDate( + 1, + 1, + 1, + options: const ConversionOptions(calendar: 'fcna'), + ), + isTrue, + ); + }); + test('1600/1/1 = true', () { + expect( + isValidHijriDate( + 1600, + 1, + 1, + options: const ConversionOptions(calendar: 'fcna'), + ), + isTrue, + ); + }); + test('0/1/1 = false', () { + expect( + isValidHijriDate( + 0, + 1, + 1, + options: const ConversionOptions(calendar: 'fcna'), + ), + isFalse, + ); + }); + }); + + // ---- FCNA daysInMonth invalid month ---- + + group('FCNA daysInMonth', () { + test('throws for month 0', () { + expect( + () => daysInHijriMonth( + 1446, + 0, + options: const ConversionOptions(calendar: 'fcna'), + ), + throwsRangeError, + ); + }); + test('throws for month 13', () { + expect( + () => daysInHijriMonth( + 1446, + 13, + options: const ConversionOptions(calendar: 'fcna'), + ), + throwsRangeError, + ); + }); + }); + + // ---- Registry ---- + + group('registry', () { + test('listCalendars includes uaq and fcna', () { + final cals = listCalendars(); + expect(cals, contains('uaq')); + expect(cals, contains('fcna')); + }); + test('getCalendar throws for unknown calendar', () { + expect(() => getCalendar('nonexistent'), throwsStateError); + }); + test('registerCalendar: custom engine works', () { + final mockEngine = _MockEngine(); + registerCalendar('mock', mockEngine); + + final cals = listCalendars(); + expect(cals, contains('mock')); + + final h = toHijri( + DateTime.utc(2020, 1, 1), + options: const ConversionOptions(calendar: 'mock'), + ); + expect(h, isNotNull); + expect(h!.hy, equals(999)); + + final g = toGregorian( + 1, + 1, + 1, + options: const ConversionOptions(calendar: 'mock'), + ); + expect(g, isNotNull); + expect(g!.toIso8601String().substring(0, 10), equals('2000-01-01')); + + expect( + isValidHijriDate( + 1, + 1, + 1, + options: const ConversionOptions(calendar: 'mock'), + ), + isTrue, + ); + expect( + daysInHijriMonth( + 1, + 1, + options: const ConversionOptions(calendar: 'mock'), + ), + equals(30), + ); + }); + }); + + // ---- Error cases ---- + + group('error cases', () { + test('UAQ toGregorian returns null for out-of-range date', () { + expect(toGregorian(1317, 1, 1), isNull); + }); + }); +} + +class _MockEngine extends CalendarEngine { + @override + String get id => 'mock'; + + @override + HijriDate? toHijri(DateTime date) => const HijriDate(hy: 999, hm: 1, hd: 1); + + @override + DateTime? toGregorian(int hy, int hm, int hd) => DateTime.utc(2000, 1, 1); + + @override + bool isValid(int hy, int hm, int hd) => + hy > 0 && hm >= 1 && hm <= 12 && hd >= 1; + + @override + int daysInMonth(int hy, int hm) => 30; +}