mirror of
https://github.com/acamarata/hijri-core-dart.git
synced 2026-06-30 18:54:27 +00:00
Initial release: hijri_core v1.0.0
This commit is contained in:
parent
0dec8df124
commit
ba0061ae60
17 changed files with 1497 additions and 0 deletions
33
.github/workflows/ci.yml
vendored
Normal file
33
.github/workflows/ci.yml
vendored
Normal 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
27
.gitignore
vendored
Normal 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
16
CHANGELOG.md
Normal 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
134
README.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# hijri_core
|
||||
|
||||
[](https://pub.dev/packages/hijri_core)
|
||||
[](https://github.com/acamarata/hijri-core-dart/actions/workflows/ci.yml)
|
||||
[](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
1
analysis_options.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
include: package:lints/recommended.yaml
|
||||
28
lib/hijri_core.dart
Normal file
28
lib/hijri_core.dart
Normal 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
5
lib/src/constants.dart
Normal 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
192
lib/src/data.dart
Normal 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
313
lib/src/engines/fcna.dart
Normal 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
141
lib/src/engines/uaq.dart
Normal 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);
|
||||
}
|
||||
51
lib/src/hijri_core_api.dart
Normal file
51
lib/src/hijri_core_api.dart
Normal 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
48
lib/src/names/months.dart
Normal 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',
|
||||
];
|
||||
25
lib/src/names/weekdays.dart
Normal file
25
lib/src/names/weekdays.dart
Normal 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
32
lib/src/registry.dart
Normal 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
80
lib/src/types.dart
Normal 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
20
pubspec.yaml
Normal 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
351
test/hijri_core_test.dart
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue