Initial release: hijri_core v1.0.0

This commit is contained in:
Aric Camarata 2026-03-08 13:02:54 -04:00
parent 0dec8df124
commit ba0061ae60
17 changed files with 1497 additions and 0 deletions

33
.github/workflows/ci.yml vendored Normal file
View file

@ -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

27
.gitignore vendored Normal file
View file

@ -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/

16
CHANGELOG.md Normal file
View file

@ -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

134
README.md Normal file
View file

@ -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.

1
analysis_options.yaml Normal file
View file

@ -0,0 +1 @@
include: package:lints/recommended.yaml

28
lib/hijri_core.dart Normal file
View file

@ -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';

5
lib/src/constants.dart Normal file
View file

@ -0,0 +1,5 @@
/// Milliseconds in one day.
const int msPerDay = 86400000;
/// Number of months in a Hijri year.
const int monthsPerYear = 12;

192
lib/src/data.dart Normal file
View file

@ -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<HijriYearRecord> 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),
];

313
lib/src/engines/fcna.dart Normal file
View file

@ -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);
}

141
lib/src/engines/uaq.dart Normal file
View file

@ -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);
}

View file

@ -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);
}

48
lib/src/names/months.dart Normal file
View file

@ -0,0 +1,48 @@
/// Hijri month names in long form.
/// Index 0 = Muharram (month 1), index 11 = Dhul Hijjah (month 12).
const List<String> 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<String> hmMedium = [
'Muharram',
'Safar',
'Rabi1',
'Rabi2',
'Jumada1',
'Jumada2',
'Rajab',
'Shaban',
'Ramadan',
'Shawwal',
'Dhul-Qidah',
'Dhul-Hijjah',
];
/// Hijri month names in short form.
const List<String> hmShort = [
'Muh',
'Saf',
'Ra1',
'Ra2',
'Ju1',
'Ju2',
'Raj',
'Shb',
'Ram',
'Shw',
'DhQ',
'DhH',
];

View file

@ -0,0 +1,25 @@
/// Hijri weekday names in long form.
/// Index 0 = Sunday, index 6 = Saturday (matching DateTime.weekday % 7).
const List<String> 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<String> 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<int> hwNumeric = [1, 2, 3, 4, 5, 6, 7];

32
lib/src/registry.dart Normal file
View file

@ -0,0 +1,32 @@
import 'types.dart';
final Map<String, CalendarEngine> _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<String> listCalendars() {
return _engines.keys.toList();
}

80
lib/src/types.dart Normal file
View file

@ -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});
}

20
pubspec.yaml Normal file
View file

@ -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

351
test/hijri_core_test.dart Normal file
View file

@ -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<Function>());
});
test('toGregorian is callable', () {
expect(toGregorian, isA<Function>());
});
test('isValidHijriDate is callable', () {
expect(isValidHijriDate, isA<Function>());
});
test('daysInHijriMonth is callable', () {
expect(daysInHijriMonth, isA<Function>());
});
test('registerCalendar is callable', () {
expect(registerCalendar, isA<Function>());
});
test('getCalendar is callable', () {
expect(getCalendar, isA<Function>());
});
test('listCalendars is callable', () {
expect(listCalendars, isA<Function>());
});
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;
}