From a9fd30ed567bd7b7dc4bf21f9907f894b75aa344 Mon Sep 17 00:00:00 2001 From: thaihuynhxyz Date: Tue, 10 Mar 2026 17:56:32 +0700 Subject: [PATCH] feat: client-side ICS download with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ics_generator.dart: generates ICS from LunarConverter for any decade, with lunar dates, Can Chi, and moon phase labels - Add browser file download helper via dart:js_interop + package:web - Replace static Google Calendar link with dynamic download button - Add 13 ICS generator tests (format, Tết, Rằm, date mapping, decade) - Update README with download instructions Made-with: Cursor --- README.md | 23 +++----- lib/ics_generator.dart | 77 +++++++++++++++++++++++++ lib/js/window.dart | 15 +++++ lib/main_view.dart | 22 +++++-- pubspec.yaml | 1 + test/ics_generator_test.dart | 108 +++++++++++++++++++++++++++++++++++ 6 files changed, 225 insertions(+), 21 deletions(-) create mode 100644 lib/ics_generator.dart create mode 100644 test/ics_generator_test.dart diff --git a/README.md b/README.md index 0410754..0385438 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/ics_generator.dart b/lib/ics_generator.dart new file mode 100644 index 0000000..9850db4 --- /dev/null +++ b/lib/ics_generator.dart @@ -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'; +} diff --git a/lib/js/window.dart b/lib/js/window.dart index 584638a..5d4134c 100644 --- a/lib/js/window.dart +++ b/lib/js/window.dart @@ -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); +} diff --git a/lib/main_view.dart b/lib/main_view.dart index ac2fd67..a10fe01 100644 --- a/lib/main_view.dart +++ b/lib/main_view.dart @@ -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'; @@ -91,7 +92,7 @@ class _MainViewState extends State { const SizedBox(height: 12), _InfoPanel(lunar: lunar), const SizedBox(height: 12), - _Footer(), + _Footer(selectedYear: solar.year), const SizedBox(height: 24), ], ), @@ -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( diff --git a/pubspec.yaml b/pubspec.yaml index 03638a0..e9f5166 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/ics_generator_test.dart b/test/ics_generator_test.dart new file mode 100644 index 0000000..75433fd --- /dev/null +++ b/test/ics_generator_test.dart @@ -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); + }); + }); +}