Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 8 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,18 @@ A Vietnamese lunar calendar ([Âm Lịch](https://amlich.demen.org)) web app bui
- Solar/Lunar focus toggle
- Dark (celestial) and Light theme
- Swipe gestures for day and month navigation
- Download .ics calendar file for any decade

## Add Vietnamese Lunar Calendar to Google Calendar
## Download Lunar Calendar (.ics)

You can subscribe to the Vietnamese lunar calendar to see lunar dates directly in your Google Calendar:
You can download the Vietnamese lunar calendar as an `.ics` file and import it into Google Calendar, Apple Calendar, Outlook, or any calendar app:

1. Open [Google Calendar](https://calendar.google.com)
2. Click **+** next to "Other calendars" in the left sidebar
3. Select **From URL**
4. Paste this URL:
1. Open [amlich.demen.org](https://amlich.demen.org)
2. Navigate to the year/decade you want
3. Click **"Tải Âm Lịch 2020s"** (or whichever decade is shown) at the bottom
4. Import the downloaded `.ics` file into your calendar app

```
https://calendar.google.com/calendar/ical/demen.org_4jc7p02lkoire319rabglmfifo%40group.calendar.google.com/public/basic.ics
```

5. Click **Add calendar**

Or click this link to add it directly:

[Add to Google Calendar](https://calendar.google.com/calendar/r?cid=demen.org_4jc7p02lkoire319rabglmfifo@group.calendar.google.com)
The file is generated client-side from the app's lunar conversion algorithm and covers a full decade (e.g. 2020-2029). Each day includes the lunar date, Can Chi, and moon phase info.

## URL Parameters

Expand Down
77 changes: 77 additions & 0 deletions lib/ics_generator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import 'package:licham/lunar_converter.dart';
import 'package:licham/widget/moon_phase.dart';

String generateIcs(int startYear, int endYear) {
final buf = StringBuffer()
..writeln('BEGIN:VCALENDAR')
..writeln('VERSION:2.0')
..writeln('PRODID:-//Am Lich//amlich.demen.org//VI')
..writeln('X-WR-CALNAME:Âm Lịch')
..writeln('X-WR-TIMEZONE:Asia/Ho_Chi_Minh');

for (var year = startYear; year <= endYear; year++) {
for (var month = 1; month <= 12; month++) {
final daysInMonth = DateTime(year, month + 1, 0).day;
for (var day = 1; day <= daysInMonth; day++) {
_writeEvent(buf, year, month, day);
}
}
}

buf.writeln('END:VCALENDAR');
return buf.toString();
}

void _writeEvent(StringBuffer buf, int year, int month, int day) {
final lunar = LunarConverter.solarToLunar(day, month, year);
final lunarDay = lunar[0];
final lunarMonth = lunar[1];
final lunarYear = lunar[2];
final isLeap = lunar[3] != 0;

final monthName =
LunarConverter.getLunarMonthName(lunarMonth, isLeap: isLeap);
final summary = _buildSummary(lunarDay, lunarMonth, lunarYear, monthName);

final jd = LunarConverter.julianDay(day, month, year);
final canChiDay = LunarConverter.getCanChiDay(jd);
final phase = moonPhaseFromLunarDay(lunarDay);
final phaseLabel = moonPhaseLabel(phase).replaceAll('\n', ' ');

final desc = StringBuffer('Ngày $canChiDay');
if (phaseLabel.isNotEmpty) desc.write(' ($phaseLabel)');

final dtStart = _formatDate(year, month, day);
final next = DateTime(year, month, day + 1);
final dtEnd = _formatDate(next.year, next.month, next.day);

buf
..writeln('BEGIN:VEVENT')
..writeln('DTSTART;VALUE=DATE:$dtStart')
..writeln('DTEND;VALUE=DATE:$dtEnd')
..writeln('SUMMARY:$summary')
..writeln('DESCRIPTION:$desc')
..writeln('END:VEVENT');
}

String _buildSummary(
int lunarDay,
int lunarMonth,
int lunarYear,
String monthName,
) {
if (lunarDay == 1 && lunarMonth == 1) {
return 'Tết Nguyên Đán ${LunarConverter.getCanChiYear(lunarYear)}'
' - Mùng 1 $monthName';
}
if (lunarDay == 1) return 'Mùng 1 $monthName';
if (lunarDay == 15) return 'Rằm $monthName';
return 'Ngày $lunarDay $monthName';
}

String _formatDate(int y, int m, int d) {
final yy = y.toString().padLeft(4, '0');
final mm = m.toString().padLeft(2, '0');
final dd = d.toString().padLeft(2, '0');
return '$yy$mm$dd';
}
15 changes: 15 additions & 0 deletions lib/js/window.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import 'dart:js_interop';

import 'package:web/web.dart' as web;

@JS('open')
external void _open(JSString url);

void open(String url) => _open(url.toJS);

void download(String filename, String content) {
final blob = web.Blob(
[content.toJS].toJS,
web.BlobPropertyBag(type: 'text/calendar;charset=utf-8'),
);
final url = web.URL.createObjectURL(blob);
(web.document.createElement('a') as web.HTMLAnchorElement
..href = url
..download = filename)
.click();
web.URL.revokeObjectURL(url);
}
22 changes: 16 additions & 6 deletions lib/main_view.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:licham/ics_generator.dart';
import 'package:licham/js/window.dart';
import 'package:licham/lunar.dart';
import 'package:licham/lunar_converter.dart';
Expand Down Expand Up @@ -91,7 +92,7 @@ class _MainViewState extends State<MainView> {
const SizedBox(height: 12),
_InfoPanel(lunar: lunar),
const SizedBox(height: 12),
_Footer(),
_Footer(selectedYear: solar.year),
const SizedBox(height: 24),
],
),
Expand Down Expand Up @@ -857,17 +858,26 @@ class _InfoPanel extends StatelessWidget {
// Footer
// ---------------------------------------------------------------------------
class _Footer extends StatelessWidget {
const _Footer({required this.selectedYear});

final int selectedYear;

@override
Widget build(BuildContext context) {
final decadeStart = selectedYear ~/ 10 * 10;
final decadeEnd = decadeStart + 9;
final label = '${decadeStart}s';

return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton.icon(
icon: const Icon(CustomIcons.calendarPlusO, size: 16),
label: const Text('Google Calendar'),
onPressed: () => open(
'https://calendar.google.com/calendar/r?cid=demen.org_4jc7p02lkoire319rabglmfifo@group.calendar.google.com',
),
icon: const Icon(Icons.download, size: 16),
label: Text('Tải Âm Lịch $label'),
onPressed: () {
final ics = generateIcs(decadeStart, decadeEnd);
download('amlich-$label.ics', ics);
},
),
const SizedBox(width: 8),
TextButton.icon(
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies:
sdk: flutter
flutter_bloc: ^9.1.1
intl: ^0.20.2
web: ^1.1.1

dev_dependencies:
build_runner: ^2.4.0
Expand Down
108 changes: 108 additions & 0 deletions test/ics_generator_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:licham/ics_generator.dart';

void main() {
group('generateIcs', () {
late String ics;

setUpAll(() {
ics = generateIcs(2024, 2024);
});

test('produces valid iCalendar wrapper', () {
expect(ics, startsWith('BEGIN:VCALENDAR'));
expect(ics, contains('VERSION:2.0'));
expect(ics, contains('PRODID:-//Am Lich//amlich.demen.org//VI'));
expect(ics, contains('X-WR-CALNAME:Âm Lịch'));
expect(ics.trimRight(), endsWith('END:VCALENDAR'));
});

test('contains one event per day for a full year', () {
final eventCount = 'BEGIN:VEVENT'.allMatches(ics).length;
expect(eventCount, 366); // 2024 is a leap year
});

test('events have required fields', () {
expect(ics, contains('DTSTART;VALUE=DATE:'));
expect(ics, contains('DTEND;VALUE=DATE:'));
expect(ics, contains('SUMMARY:'));
expect(ics, contains('DESCRIPTION:'));
});

test('Tết 2024 event has correct summary', () {
expect(
ics,
contains(
'SUMMARY:Tết Nguyên Đán Giáp Thìn - Mùng 1 Tháng Giêng',
),
);
});

test('Tết 2024 is on Feb 10', () {
final lines = ics.split('\n');
final tetIndex = lines.indexWhere(
(l) => l.contains('Tết Nguyên Đán Giáp Thìn'),
);
final dtStart = lines[tetIndex - 2];
expect(dtStart, contains('20240210'));
});

test('Rằm Trung Thu 2024 event exists', () {
expect(ics, contains('SUMMARY:Rằm Tháng Tám'));
});

test('Rằm Trung Thu 2024 is on Sep 17', () {
final lines = ics.split('\n');
final ramIndex = lines.indexWhere(
(l) => l.contains('Rằm Tháng Tám'),
);
final dtStart = lines[ramIndex - 2];
expect(dtStart, contains('20240917'));
});

test('Mùng 1 events for each lunar month', () {
final mung1Count = 'SUMMARY:Mùng 1'.allMatches(ics).length;
// 12 or 13 months (leap year may have extra), minus the Tết one
expect(mung1Count, greaterThanOrEqualTo(11));
});

test('description includes Can Chi', () {
expect(ics, contains('DESCRIPTION:Ngày'));
});

test('description includes moon phase for key days', () {
expect(ics, contains('(Sóc)'));
expect(ics, contains('(Vọng)'));
});

test('date format is YYYYMMDD', () {
expect(ics, contains('DTSTART;VALUE=DATE:20240101'));
expect(ics, contains('DTEND;VALUE=DATE:20240102'));
expect(ics, contains('DTSTART;VALUE=DATE:20241231'));
expect(ics, contains('DTEND;VALUE=DATE:20250101'));
});
});

group('multi-year generation', () {
test('decade covers all years', () {
final ics = generateIcs(2020, 2029);
for (var year = 2020; year <= 2029; year++) {
expect(
ics,
contains('DTSTART;VALUE=DATE:${year}0101'),
reason: 'Missing Jan 1 of $year',
);
}
});

test('decade event count matches total days', () {
final ics = generateIcs(2020, 2029);
final eventCount = 'BEGIN:VEVENT'.allMatches(ics).length;
var totalDays = 0;
for (var y = 2020; y <= 2029; y++) {
totalDays += DateTime(y + 1).difference(DateTime(y)).inDays;
}
expect(eventCount, totalDays);
});
});
}