diff --git a/lib/lunar_converter.dart b/lib/lunar_converter.dart new file mode 100644 index 0000000..adfc543 --- /dev/null +++ b/lib/lunar_converter.dart @@ -0,0 +1,296 @@ +import 'dart:math' as math; + +class LunarConverter { + LunarConverter._(); + + static const List lunarMonths = [ + 'Tháng Giêng', + 'Tháng Hai', + 'Tháng Ba', + 'Tháng Tư', + 'Tháng Năm', + 'Tháng Sáu', + 'Tháng Bảy', + 'Tháng Tám', + 'Tháng Chín', + 'Tháng Mười', + 'Tháng Một', + 'Tháng Chạp', + ]; + + static const List can = [ + 'Canh', 'Tân', 'Nhâm', 'Quý', 'Giáp', // + 'Ất', 'Bính', 'Đinh', 'Mậu', 'Kỷ', + ]; + + static const List chi = [ + 'Thân', 'Dậu', 'Tuất', 'Hợi', 'Tí', 'Sửu', // + 'Dần', 'Mão', 'Thìn', 'Tỵ', 'Ngọ', 'Mùi', + ]; + + static const List _auspiciousPattern = [ + true, true, false, false, true, true, // + false, true, false, false, true, false, + ]; + + static const List hourNames = [ + 'Tí (23h-1h)', + 'Sửu (1h-3h)', + 'Dần (3h-5h)', + 'Mão (5h-7h)', + 'Thìn (7h-9h)', + 'Tỵ (9h-11h)', + 'Ngọ (11h-13h)', + 'Mùi (13h-15h)', + 'Thân (15h-17h)', + 'Dậu (17h-19h)', + 'Tuất (19h-21h)', + 'Hợi (21h-23h)', + ]; + + /// Returns [lunarDay, lunarMonth, lunarYear, lunarLeap]. + static List solarToLunar( + int dd, + int mm, + int yy, { + double timeZone = 7, + }) { + int lunarDay; + int lunarMonth; + int lunarYear; + int lunarLeap; + final dayNumber = julianDay(dd, mm, yy); + + final k = ((dayNumber - 2415021.076998695) / 29.530588853).floor(); + var monthStart = _getNewMoonDay(k + 1, timeZone); + if (monthStart > dayNumber) { + monthStart = _getNewMoonDay(k, timeZone); + } + var a11 = _getLunarMonth11(yy, timeZone); + var b11 = a11; + if (a11 >= monthStart) { + lunarYear = yy; + a11 = _getLunarMonth11(yy - 1, timeZone); + } else { + lunarYear = yy + 1; + b11 = _getLunarMonth11(yy + 1, timeZone); + } + + lunarDay = dayNumber - monthStart + 1; + final diff = ((monthStart - a11) / 29).floor(); + lunarLeap = 0; + lunarMonth = diff + 11; + if (b11 - a11 > 365) { + final leapMonthDiff = _getLeapMonthOffset(a11, timeZone); + if (diff >= leapMonthDiff) { + lunarMonth = diff + 10; + if (diff == leapMonthDiff) { + lunarLeap = 1; + } + } + } + if (lunarMonth > 12) { + lunarMonth = lunarMonth - 12; + } + if (lunarMonth >= 11 && diff < 4) { + lunarYear -= 1; + } + return [lunarDay, lunarMonth, lunarYear, lunarLeap]; + } + + static DateTime lunarToSolar( + int lunarDay, + int lunarMonth, + int lunarYear, + int lunarLeap, { + double timeZone = 7, + }) { + int a11; + int b11; + if (lunarMonth < 11) { + a11 = _getLunarMonth11(lunarYear - 1, timeZone); + b11 = _getLunarMonth11(lunarYear, timeZone); + } else { + a11 = _getLunarMonth11(lunarYear, timeZone); + b11 = _getLunarMonth11(lunarYear + 1, timeZone); + } + final k = (0.5 + (a11 - 2415021.076998695) / 29.530588853).floor(); + var off = lunarMonth - 11; + if (off < 0) { + off += 12; + } + if (b11 - a11 > 365) { + final leapOff = _getLeapMonthOffset(a11, timeZone); + var leapMonth = leapOff - 2; + if (leapMonth < 0) { + leapMonth += 12; + } + if (lunarLeap != 0 && lunarMonth != leapMonth) { + return DateTime(0); + } else if (lunarLeap != 0 || off >= leapOff) { + off += 1; + } + } + final monthStart = _getNewMoonDay(k + off, timeZone); + return _jdToDate(monthStart + lunarDay - 1); + } + + static int julianDay(int dd, int mm, int yy) { + final a = (14 - mm) ~/ 12; + final y = yy + 4800 - a; + final m = mm + 12 * a - 3; + var jd = dd + + (153 * m + 2) ~/ 5 + + 365 * y + + y ~/ 4 - + y ~/ 100 + + y ~/ 400 - + 32045; + if (jd < 2299161) { + jd = dd + (153 * m + 2) ~/ 5 + 365 * y + y ~/ 4 - 32083; + } + return jd; + } + + static String getCanChiDay(int jd) { + return '${can[(jd + 3) % can.length]} ${chi[(jd + 5) % chi.length]}'; + } + + static String getCanChiMonth(int lunarMonth, int year) { + final canYearIndex = year % can.length; + final offset = canYearIndex % 5 + ((canYearIndex % 5 + 7) % can.length) * 2; + return '${can[(offset + lunarMonth - 1) % can.length]} ' + '${chi[(lunarMonth + 5) % chi.length]}'; + } + + static String getCanChiYear(int year) { + return '${can[year % can.length]} ${chi[year % chi.length]}'; + } + + static List getAuspiciousHours(int jd) { + final chiDayIndex = (jd + 5) % chi.length; + final shift = (chiDayIndex % 6) * 2; + final hours = []; + for (var i = 0; i < 12; i++) { + if (_auspiciousPattern[(i - shift) % 12]) { + hours.add(hourNames[i]); + } + } + return hours; + } + + static String getLunarMonthName(int month, {bool isLeap = false}) { + final name = lunarMonths[month - 1]; + return isLeap ? '$name Nhuận' : name; + } + + // -- Private helpers -- + + static int _getLeapMonthOffset(int a11, double timeZone) { + final k = (0.5 + (a11 - 2415021.076998695) / 29.530588853).floor(); + int last; + var i = 1; + var arc = (_getSunLongitude(_getNewMoonDay(k + i, timeZone), timeZone) / 30) + .floor(); + do { + last = arc; + i++; + arc = (_getSunLongitude(_getNewMoonDay(k + i, timeZone), timeZone) / 30) + .floor(); + } while (arc != last && i < 14); + return i - 1; + } + + static double _sunLongitudeAA98(double jdn) { + final t = (jdn - 2451545.0) / 36525; + final t2 = t * t; + const dr = math.pi / 180; + final m = + 357.52910 + 35999.05030 * t - 0.0001559 * t2 - 0.00000048 * t * t2; + final l0 = 280.46645 + 36000.76983 * t + 0.0003032 * t2; + var dl = (1.914600 - 0.004817 * t - 0.000014 * t2) * math.sin(dr * m); + dl = dl + + (0.019993 - 0.000101 * t) * math.sin(dr * 2 * m) + + 0.000290 * math.sin(dr * 3 * m); + final l = l0 + dl; + return l - 360 * (l / 360).floor(); + } + + static double _getSunLongitude(int dayNumber, double timeZone) { + return _sunLongitudeAA98(dayNumber - 0.5 - timeZone / 24); + } + + static int _getLunarMonth11(int yy, double timeZone) { + final off = julianDay(31, 12, yy) - 2415021.076998695; + final k = (off / 29.530588853).floor(); + var nm = _getNewMoonDay(k, timeZone); + final sunLong = (_getSunLongitude(nm, timeZone) / 30).floor(); + if (sunLong >= 9) { + nm = _getNewMoonDay(k - 1, timeZone); + } + return nm; + } + + static double _newMoonAA98(int k) { + final t = k / 1236.85; + final t2 = t * t; + final t3 = t2 * t; + const dr = math.pi / 180; + var jd1 = + 2415020.75933 + 29.53058868 * k + 0.0001178 * t2 - 0.000000155 * t3; + jd1 = jd1 + 0.00033 * math.sin((166.56 + 132.87 * t - 0.009173 * t2) * dr); + final m = 359.2242 + 29.10535608 * k - 0.0000333 * t2 - 0.00000347 * t3; + final mpr = 306.0253 + 385.81691806 * k + 0.0107306 * t2 + 0.00001236 * t3; + final f = 21.2964 + 390.67050646 * k - 0.0016528 * t2 - 0.00000239 * t3; + var c1 = (0.1734 - 0.000393 * t) * math.sin(m * dr) + + 0.0021 * math.sin(2 * dr * m); + c1 = c1 - 0.4068 * math.sin(mpr * dr) + 0.0161 * math.sin(dr * 2 * mpr); + c1 = c1 - 0.0004 * math.sin(dr * 3 * mpr); + c1 = c1 + 0.0104 * math.sin(dr * 2 * f) - 0.0051 * math.sin(dr * (m + mpr)); + c1 = c1 - + 0.0074 * math.sin(dr * (m - mpr)) + + 0.0004 * math.sin(dr * (2 * f + m)); + c1 = c1 - + 0.0004 * math.sin(dr * (2 * f - m)) - + 0.0006 * math.sin(dr * (2 * f + mpr)); + c1 = c1 + + 0.0010 * math.sin(dr * (2 * f - mpr)) + + 0.0005 * math.sin(dr * (2 * mpr + m)); + double deltat; + if (t < -11) { + deltat = 0.001 + + 0.000839 * t + + 0.0002261 * t2 - + 0.00000845 * t3 - + 0.000000081 * t * t3; + } else { + deltat = -0.000278 + 0.000265 * t + 0.000262 * t2; + } + return jd1 + c1 - deltat; + } + + static int _getNewMoonDay(int k, double timeZone) { + final jd = _newMoonAA98(k); + return (jd + 0.5 + timeZone / 24).floor(); + } + + static DateTime _jdToDate(int jd) { + int b; + int c; + if (jd > 2299160) { + final a = jd + 32044; + b = (4 * a + 3) ~/ 146097; + c = a - (b * 146097) ~/ 4; + } else { + b = 0; + c = jd + 32082; + } + final d = (4 * c + 3) ~/ 1461; + final e = c - (1461 * d) ~/ 4; + final m = (5 * e + 2) ~/ 153; + final day = e - (153 * m + 2) ~/ 5 + 1; + final month = m + 3 - 12 * (m ~/ 10); + final year = b * 100 + d - 4800 + m ~/ 10; + return DateTime(year, month, day); + } +} diff --git a/lib/main.dart b/lib/main.dart index ae3611d..f04a6b9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,8 @@ import 'package:licham/main_bloc.dart'; import 'package:licham/main_event.dart'; import 'package:licham/main_view.dart'; +final themeMode = ValueNotifier(ThemeMode.dark); + void main() { final date = _getDate(Uri.base.queryParameters); runApp( @@ -22,10 +24,18 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp( - theme: _buildDarkTheme(), - title: 'Âm Lịch', - home: const MainView(), + return ValueListenableBuilder( + valueListenable: themeMode, + builder: (context, mode, _) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: _lightTheme(), + darkTheme: _darkTheme(), + themeMode: mode, + title: 'Âm Lịch', + home: const MainView(), + ); + }, ); } } @@ -41,20 +51,113 @@ DateTime _getDate(Map queryParameters) { } } -ThemeData _buildDarkTheme() { - const primaryColor = Color(0xFF0175c2); - const secondaryColor = Color(0xFF13B9FD); +// Celestial palette +const _navy = Color(0xFF0F1B2D); +const _surface = Color(0xFF162036); +const _gold = Color(0xFFF5E6CA); +const _accent = Color(0xFFE8A838); +const _red = Color(0xFFD4534B); +const _teal = Color(0xFF16A085); + +ThemeData _darkTheme() { const colorScheme = ColorScheme.dark( - primary: primaryColor, - secondary: secondaryColor, - error: Color(0xFFB00020), - surface: Color(0xFF202124), + primary: _accent, + secondary: _red, + surface: _surface, + surfaceContainerHighest: _navy, + onSurface: _gold, + onPrimary: _navy, ); return ThemeData( + useMaterial3: true, brightness: Brightness.dark, colorScheme: colorScheme, - primaryColor: primaryColor, - canvasColor: const Color(0xFF202124), - scaffoldBackgroundColor: const Color(0xFF202124), + scaffoldBackgroundColor: _navy, + cardTheme: CardThemeData( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + color: _surface, + ), + appBarTheme: const AppBarTheme( + backgroundColor: _navy, + foregroundColor: _gold, + elevation: 0, + scrolledUnderElevation: 0, + ), + chipTheme: ChipThemeData( + backgroundColor: _navy, + labelStyle: TextStyle(color: _gold.withValues(alpha: 0.9), fontSize: 12), + side: BorderSide(color: _gold.withValues(alpha: 0.2)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + iconTheme: IconThemeData(color: _gold.withValues(alpha: 0.8)), + textTheme: TextTheme( + displayLarge: const TextStyle( + fontSize: 72, + fontWeight: FontWeight.w200, + color: _gold, + ), + headlineMedium: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w600, + color: _gold, + ), + titleMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: _gold.withValues(alpha: 0.7), + ), + bodyLarge: TextStyle( + fontSize: 15, + color: _gold.withValues(alpha: 0.9), + ), + bodyMedium: TextStyle( + fontSize: 13, + color: _gold.withValues(alpha: 0.6), + ), + ), + ); +} + +ThemeData _lightTheme() { + const bg = Color(0xFFF5F5F7); + const colorScheme = ColorScheme.light( + primary: _teal, + secondary: _red, + surfaceContainerHighest: bg, + ); + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + scaffoldBackgroundColor: bg, + cardTheme: CardThemeData( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + appBarTheme: const AppBarTheme( + backgroundColor: bg, + foregroundColor: Color(0xFF111827), + elevation: 0, + scrolledUnderElevation: 0, + ), + textTheme: const TextTheme( + displayLarge: TextStyle( + fontSize: 72, + fontWeight: FontWeight.w200, + color: Color(0xFF111827), + ), + headlineMedium: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w600, + color: Color(0xFF111827), + ), + titleMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF6B7280), + ), + bodyLarge: TextStyle(fontSize: 15, color: Color(0xFF374151)), + bodyMedium: TextStyle(fontSize: 13, color: Color(0xFF6B7280)), + ), ); } diff --git a/lib/main_bloc.dart b/lib/main_bloc.dart index 55eaf75..9ea8a35 100644 --- a/lib/main_bloc.dart +++ b/lib/main_bloc.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'dart:math' as math; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; import 'package:licham/lunar.dart'; +import 'package:licham/lunar_converter.dart'; import 'package:licham/main_event.dart'; import 'package:licham/main_state.dart'; @@ -35,12 +35,11 @@ class MainBloc extends Bloc { add(const SolarChanged()); } else if (event is LunarSelected) { final ddMMyyyy = event.lunar!.split('/'); - solarDate = _convertLunar2Solar( + solarDate = LunarConverter.lunarToSolar( int.parse(ddMMyyyy[0]), int.parse(ddMMyyyy[1]), int.parse(ddMMyyyy[2]), lunarMonth!.endsWith('Nhuận') ? 1 : 0, - 7, ); add(const SolarChanged()); } @@ -53,86 +52,7 @@ class MainBloc extends Bloc { DateTime? solarDate; Lunar? lunarDate; - - static final List _lunarMonths = [ - 'Tháng Giêng', - 'Tháng Hai', - 'Tháng Ba', - 'Tháng Tư', - 'Tháng Năm', - 'Tháng Sáu', - 'Tháng Bảy', - 'Tháng Tám', - 'Tháng Chín', - 'Tháng Mười', - 'Tháng Một', - 'Tháng Chạp', - ]; - - static final List _can = [ - 'Canh', - 'Tân', - 'Nhâm', - 'Quý', - 'Giáp', - 'Ất', - 'Bính', - 'Đinh', - 'Mậu', - 'Kỷ', - ]; - - static final List _chi = [ - 'Thân', - 'Dậu', - 'Tuất', - 'Hợi', - 'Tí', - 'Sửu', - 'Dần', - 'Mão', - 'Thìn', - 'Tỵ', - 'Ngọ', - 'Mùi', - ]; - - static final List _d = [ - true, - true, - false, - false, - true, - true, - false, - true, - false, - false, - true, - false, - ]; - - static final List _hours = [ - 'Tí (23h-1h)', - 'Sửu (1h-3h)', - 'Dần (3h-5h)', - 'Mão (5h-7h)', - 'Thìn (7h-9h)', - 'Tỵ (9h-11h)', - 'Ngọ (11h-13h)', - 'Mùi (13h-15h)', - 'Thân (15h-17h)', - 'Dậu (17h-19h)', - 'Tuất (19h-21h)', - 'Hợi (21h-23h)', - ]; - - static int lunarMonthIndex = 0; String? lunarMonth; - String? canChiDay; - String? canChiMonth; - String? canChiYear; - List? auspiciousHours; void _initDateFormat() { weekFormat = DateFormat('EEEE', 'vi'); @@ -140,251 +60,27 @@ class MainBloc extends Bloc { ddMMyyyyFormat = DateFormat('dd/MM/yyyy', 'vi'); } - int _getLeapMonthOffset(int a11, double timeZone) { - final k = (0.5 + (a11 - 2415021.076998695) / 29.530588853).floor(); - int last; - var i = 1; - var arc = (_getSunLongitude(_getNewMoonDay(k + i, timeZone), timeZone) / 30) - .floor(); - do { - last = arc; - i++; - arc = (_getSunLongitude(_getNewMoonDay(k + i, timeZone), timeZone) / 30) - .floor(); - } while (arc != last && i < 14); - return i - 1; - } - - double _sunLongitudeAA98(double jdn) { - final t = (jdn - 2451545.0) / 36525; - final t2 = t * t; - const dr = math.pi / 180; - final m = - 357.52910 + 35999.05030 * t - 0.0001559 * t2 - 0.00000048 * t * t2; - final l0 = 280.46645 + 36000.76983 * t + 0.0003032 * t2; - var dl = (1.914600 - 0.004817 * t - 0.000014 * t2) * math.sin(dr * m); - dl = dl + - (0.019993 - 0.000101 * t) * math.sin(dr * 2 * m) + - 0.000290 * math.sin(dr * 3 * m); - final l = l0 + dl; - return l - 360 * (l / 360).floor(); - } - - double _getSunLongitude(int dayNumber, double timeZone) { - return _sunLongitudeAA98(dayNumber - 0.5 - timeZone / 24); - } - - int _getLunarMonth11(int yy, double timeZone) { - final off = _jdFromDate(31, 12, yy) - 2415021.076998695; - final k = (off / 29.530588853).floor(); - var nm = _getNewMoonDay(k, timeZone); - final sunLong = (_getSunLongitude(nm, timeZone) / 30).floor(); - if (sunLong >= 9) { - nm = _getNewMoonDay(k - 1, timeZone); - } - return nm; - } - - double _newMoonAA98(int k) { - final t = k / 1236.85; - final t2 = t * t; - final t3 = t2 * t; - const dr = math.pi / 180; - var jd1 = - 2415020.75933 + 29.53058868 * k + 0.0001178 * t2 - 0.000000155 * t3; - jd1 = jd1 + 0.00033 * math.sin((166.56 + 132.87 * t - 0.009173 * t2) * dr); - final m = 359.2242 + 29.10535608 * k - 0.0000333 * t2 - 0.00000347 * t3; - final mpr = 306.0253 + 385.81691806 * k + 0.0107306 * t2 + 0.00001236 * t3; - final f = 21.2964 + 390.67050646 * k - 0.0016528 * t2 - 0.00000239 * t3; - var c1 = (0.1734 - 0.000393 * t) * math.sin(m * dr) + - 0.0021 * math.sin(2 * dr * m); - c1 = c1 - 0.4068 * math.sin(mpr * dr) + 0.0161 * math.sin(dr * 2 * mpr); - c1 = c1 - 0.0004 * math.sin(dr * 3 * mpr); - c1 = c1 + 0.0104 * math.sin(dr * 2 * f) - 0.0051 * math.sin(dr * (m + mpr)); - c1 = c1 - - 0.0074 * math.sin(dr * (m - mpr)) + - 0.0004 * math.sin(dr * (2 * f + m)); - c1 = c1 - - 0.0004 * math.sin(dr * (2 * f - m)) - - 0.0006 * math.sin(dr * (2 * f + mpr)); - c1 = c1 + - 0.0010 * math.sin(dr * (2 * f - mpr)) + - 0.0005 * math.sin(dr * (2 * mpr + m)); - double deltat; - if (t < -11) { - deltat = 0.001 + - 0.000839 * t + - 0.0002261 * t2 - - 0.00000845 * t3 - - 0.000000081 * t * t3; - } else { - deltat = -0.000278 + 0.000265 * t + 0.000262 * t2; - } - final jdNew = jd1 + c1 - deltat; - return jdNew; - } - - int _getNewMoonDay(int k, double timeZone) { - final jd = _newMoonAA98(k); - return (jd + 0.5 + timeZone / 24).floor(); - } - - int _jdFromDate(int dd, int mm, int yy) { - final a = (14 - mm) ~/ 12; - final y = yy + 4800 - a; - final m = mm + 12 * a - 3; - var jd = dd + - (153 * m + 2) ~/ 5 + - 365 * y + - y ~/ 4 - - y ~/ 100 + - y ~/ 400 - - 32045; - if (jd < 2299161) { - jd = dd + (153 * m + 2) ~/ 5 + 365 * y + y ~/ 4 - 32083; - } - return jd; - } - - List _convertSolar2Lunar(int dd, int mm, int yy, double timeZone) { - int lunarDay; - int lunarMonth; - int lunarYear; - int lunarLeap; - final dayNumber = _jdFromDate(dd, mm, yy); - - final chiDayIndex = (dayNumber + 5) % _chi.length; - canChiDay = '${_can[(dayNumber + 3) % _can.length]} ${_chi[chiDayIndex]}'; - - auspiciousHours = []; - final shiftD = (chiDayIndex % 6) * 2; - for (var i = 0; i < 12; i++) { - if (_d[(i - shiftD) % 12]) { - auspiciousHours!.add(_hours[i]); - } - } - - final k = ((dayNumber - 2415021.076998695) / 29.530588853).floor(); - var monthStart = _getNewMoonDay(k + 1, timeZone); - if (monthStart > dayNumber) { - monthStart = _getNewMoonDay(k, timeZone); - } - var a11 = _getLunarMonth11(yy, timeZone); - var b11 = a11; - if (a11 >= monthStart) { - lunarYear = yy; - a11 = _getLunarMonth11(yy - 1, timeZone); - } else { - lunarYear = yy + 1; - b11 = _getLunarMonth11(yy + 1, timeZone); - } - - lunarDay = dayNumber - monthStart + 1; - final diff = ((monthStart - a11) / 29).floor(); - lunarLeap = 0; - lunarMonth = diff + 11; - if (b11 - a11 > 365) { - final leapMonthDiff = _getLeapMonthOffset(a11, timeZone); - if (diff >= leapMonthDiff) { - lunarMonth = diff + 10; - if (diff == leapMonthDiff) { - lunarLeap = 1; - } - } - } - if (lunarMonth > 12) { - lunarMonth = lunarMonth - 12; - } - if (lunarMonth >= 11 && diff < 4) { - lunarYear -= 1; - } - return [lunarDay, lunarMonth, lunarYear, lunarLeap]; - } - Lunar _calculate(DateTime date) { - final result = _convertSolar2Lunar(date.day, date.month, date.year, 7); - lunarMonthIndex = result[1]; + final jd = LunarConverter.julianDay(date.day, date.month, date.year); + final result = LunarConverter.solarToLunar(date.day, date.month, date.year); + final lunarMonthIndex = result[1]; final year = result[2]; - lunarMonth = _lunarMonths[lunarMonthIndex - 1]; - if (result[3] != 0) lunarMonth = '$lunarMonth Nhuận'; - - final canYearIndex = year % _can.length; - final canMonthOffset = - canYearIndex % 5 + ((canYearIndex % 5 + 7) % _can.length) * 2; + final isLeap = result[3] != 0; - canChiMonth = - '${_can[(canMonthOffset + lunarMonthIndex - 1) % _can.length]} ' - '${_chi[(lunarMonthIndex + 5) % _chi.length]}'; - canChiYear = '${_can[canYearIndex]} ${_chi[year % _chi.length]}'; + lunarMonth = LunarConverter.getLunarMonthName( + lunarMonthIndex, + isLeap: isLeap, + ); return Lunar( result[0], result[1], result[2], lunarMonth, - canChiDay, - canChiMonth, - canChiYear, - auspiciousHours, + LunarConverter.getCanChiDay(jd), + LunarConverter.getCanChiMonth(lunarMonthIndex, year), + LunarConverter.getCanChiYear(year), + LunarConverter.getAuspiciousHours(jd), ); } - - /// http://www.tondering.dk/claus/calendar.html - /// Section: Is there a formula for calculating the Julian day number? - static DateTime _jdToDate(int jd) { - int b; - int c; - if (jd > 2299160) { - final a = jd + 32044; - b = (4 * a + 3) ~/ 146097; - c = a - (b * 146097) ~/ 4; - } else { - b = 0; - c = jd + 32082; - } - final d = (4 * c + 3) ~/ 1461; - final e = c - (1461 * d) ~/ 4; - final m = (5 * e + 2) ~/ 153; - final day = e - (153 * m + 2) ~/ 5 + 1; - final month = m + 3 - 12 * (m ~/ 10); - final year = b * 100 + d - 4800 + m ~/ 10; - return DateTime(year, month, day); - } - - DateTime _convertLunar2Solar( - int lunarDay, - int lunarMonth, - int lunarYear, - int lunarLeap, - double timeZone, - ) { - int a11; - int b11; - if (lunarMonth < 11) { - a11 = _getLunarMonth11(lunarYear - 1, timeZone); - b11 = _getLunarMonth11(lunarYear, timeZone); - } else { - a11 = _getLunarMonth11(lunarYear, timeZone); - b11 = _getLunarMonth11(lunarYear + 1, timeZone); - } - final k = (0.5 + (a11 - 2415021.076998695) / 29.530588853).floor(); - var off = lunarMonth - 11; - if (off < 0) { - off += 12; - } - if (b11 - a11 > 365) { - final leapOff = _getLeapMonthOffset(a11, timeZone); - var leapMonth = leapOff - 2; - if (leapMonth < 0) { - leapMonth += 12; - } - if (lunarLeap != 0 && lunarMonth != leapMonth) { - return DateTime(0); - } else if (lunarLeap != 0 || off >= leapOff) { - off += 1; - } - } - final monthStart = _getNewMoonDay(k + off, timeZone); - return _jdToDate(monthStart + lunarDay - 1); - } } diff --git a/lib/main_view.dart b/lib/main_view.dart index c3d45d9..1d550b9 100644 --- a/lib/main_view.dart +++ b/lib/main_view.dart @@ -1,267 +1,185 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:licham/choice.dart'; import 'package:licham/js/window.dart'; +import 'package:licham/lunar.dart'; +import 'package:licham/lunar_converter.dart'; +import 'package:licham/main.dart'; import 'package:licham/main_bloc.dart'; import 'package:licham/main_event.dart'; import 'package:licham/main_state.dart'; import 'package:licham/widget/loading_indicator.dart'; +import 'package:licham/widget/moon_phase.dart'; import 'package:licham/widget/my_flutter_app_icons.dart'; class MainView extends StatefulWidget { const MainView({super.key}); @override - State createState() => _MainState(); + State createState() => _MainViewState(); } -class _MainState extends State { - static const List choices = [ - Choice(title: 'Hôm Nay', icon: Icons.calendar_today), - Choice(title: 'Tìm Ngày Dương', icon: CustomIcons.sun), - Choice(title: 'Tìm Ngày Âm', icon: CustomIcons.moon), - ]; - DateTime? _solar = DateTime.now(); - final GlobalKey _formKey = GlobalKey(); +class _MainViewState extends State { + final _formKey = GlobalKey(); + bool _lunarFocus = true; @override Widget build(BuildContext context) { - final mainBloc = BlocProvider.of(context); - return BlocListener( - listener: (context, state) { - if (state is MainFailure) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.error), - backgroundColor: Colors.red, - ), - ); - } - }, - child: BlocBuilder( - buildWhen: (previousState, currentState) => currentState is DateUpdate, - builder: (context, state) { - if (state is DateUpdate) { - _solar = state.solar; - return Scaffold( - appBar: AppBar( - title: Text( - mainBloc.monthYearFormat.format(_solar!).toUpperCase(), - ), - centerTitle: true, - actions: [ - PopupMenuButton( - onSelected: (choice) async { - switch (choices.indexOf(choice)) { - case 0: - mainBloc.add(const TodaySelected()); - case 1: - final picked = await showDatePicker( - context: context, - initialDate: _solar, - firstDate: DateTime(1), - lastDate: DateTime(3000), - ); - mainBloc.add(SolarSelected(solar: picked)); - case 2: - if (!context.mounted) return; - await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Ngày Âm'), - content: Form( - key: _formKey, - autovalidateMode: - AutovalidateMode.onUserInteraction, - child: TextFormField( - keyboardType: TextInputType.datetime, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Ngày/Tháng/Năm', - ), - validator: _validateNgayThangNam, - onSaved: (value) { - Navigator.pop(context); - mainBloc.add(LunarSelected(lunar: value)); - }, - ), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text('DISAGREE'), - ), - TextButton( - onPressed: _handleSubmitted, - child: const Text('AGREE'), - ), - ], - ); - }, - ); - } - }, - itemBuilder: (context) { - return choices.map((choice) { - return PopupMenuItem( - value: choice, - child: ListTile( - leading: Icon(choice.icon), - title: Text(choice.title!), - ), - ); - }).toList(); - }, + final bloc = context.read(); + return BlocBuilder( + buildWhen: (_, current) => current is DateUpdate, + builder: (context, state) { + if (state is! DateUpdate) return const LoadingIndicator(); + + final solar = state.solar!; + final lunar = state.lunar!; + + return Scaffold( + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: ListView( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, ), - ], - ), - body: LayoutBuilder( - builder: (context, constraints) => Column( - children: [ - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: const Icon(Icons.chevron_left), - onPressed: () { - mainBloc.add(const PreviousSelected()); - }, - ), - Column( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Text( - '${_solar!.day}', - style: Theme.of(context).textTheme.displayLarge, - ), - Text( - mainBloc.weekFormat.format(_solar!), - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - IconButton( - icon: const Icon(Icons.chevron_right), - onPressed: () { - mainBloc.add(const NextSelected()); - }, - ), - ], - ), + children: [ + _Header( + onSearch: () => _showSearchDialog(bloc), + lunarFocus: _lunarFocus, + onToggleFocus: () { + setState(() => _lunarFocus = !_lunarFocus); + }, ), - const Divider(thickness: 8), - SizedBox( - height: - (constraints.maxHeight - constraints.minHeight) * 0.4, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - 'Tháng ${state.lunar!.canChiMonth}', - style: Theme.of(context).textTheme.bodyLarge, - ), - Text( - 'Ngày ${state.lunar!.canChiDay}', - style: Theme.of(context).textTheme.bodyLarge, - ), - Text( - 'Năm ${state.lunar!.canchiYear}', - style: Theme.of(context).textTheme.bodyLarge, - ), - ], - ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${state.lunar!.monthString}', - style: Theme.of(context).textTheme.titleMedium, - ), - Text( - '${state.lunar!.day}', - style: - Theme.of(context).textTheme.displayMedium, - ), - Text( - 'Năm ${state.lunar!.year}', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.start, - children: state.lunar!.hours! - .map( - (hour) => Text( - hour, - style: - Theme.of(context).textTheme.bodyLarge, - ), - ) - .toList(), - ), - ], - ), + const SizedBox(height: 12), + _TodayCard( + solar: solar, + lunar: lunar, + bloc: bloc, + lunarFocus: _lunarFocus, ), - ], - ), - ), - floatingActionButtonLocation: - FloatingActionButtonLocation.centerDocked, - floatingActionButton: FloatingActionButton( - onPressed: _openCalendar, - child: const Icon(CustomIcons.calendarPlusO), - ), - bottomNavigationBar: BottomAppBar( - shape: const CircularNotchedRectangle(), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - IconButton( - icon: const Icon(CustomIcons.downCircled2), - onPressed: _openDownload, - ), - IconButton( - icon: const Icon(CustomIcons.githubCircled), - onPressed: _openGithub, + const SizedBox(height: 12), + _MonthCalendar( + selectedDate: solar, + selectedLunar: lunar, + lunarFocus: _lunarFocus, + onDateSelected: (date) { + bloc.add(SolarSelected(solar: date)); + }, ), + const SizedBox(height: 12), + _InfoPanel(lunar: lunar), + const SizedBox(height: 12), + _Footer(), + const SizedBox(height: 24), ], ), ), - ); - } - return const LoadingIndicator(); - }, - ), + ), + ), + ); + }, ); } - void _openCalendar() { - open( - 'https://calendar.google.com/calendar/r?cid=demen.org_4jc7p02lkoire319rabglmfifo@group.calendar.google.com', + Future _showSearchDialog(MainBloc bloc) { + if (_lunarFocus) { + return _showSolarSearchDialog(bloc); + } + return _showLunarSearchDialog(bloc); + } + + Future _showLunarSearchDialog(MainBloc bloc) { + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Tìm ngày Âm'), + content: Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: TextFormField( + keyboardType: TextInputType.datetime, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Ngày/Tháng/Năm Âm lịch', + hintText: '15/1/2024', + ), + validator: _validateLunarDate, + onSaved: (value) { + Navigator.pop(context); + bloc.add(LunarSelected(lunar: value)); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Huỷ'), + ), + FilledButton( + onPressed: _submitForm, + child: const Text('Tìm'), + ), + ], + ); + }, ); } - void _openDownload() { - open( - 'https://calendar.google.com/calendar/ical/demen.org_4jc7p02lkoire319rabglmfifo%40group.calendar.google.com/public/basic.ics', + Future _showSolarSearchDialog(MainBloc bloc) { + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Tìm ngày Dương'), + content: Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: TextFormField( + keyboardType: TextInputType.datetime, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Ngày/Tháng/Năm Dương lịch', + hintText: '10/3/2026', + ), + validator: _validateSolarDate, + onSaved: (value) { + Navigator.pop(context); + final parts = value!.split('/'); + bloc.add( + SolarSelected( + solar: DateTime( + int.parse(parts[2]), + int.parse(parts[1]), + int.parse(parts[0]), + ), + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Huỷ'), + ), + FilledButton( + onPressed: _submitForm, + child: const Text('Tìm'), + ), + ], + ); + }, ); } - void _openGithub() { - open('https://github.com/de-men/amlich'); + void _submitForm() { + final form = _formKey.currentState!; + if (form.validate()) form.save(); } - String? _validateNgayThangNam(String? value) { + String? _validateLunarDate(String? value) { if (value == null || value.isEmpty) return 'Cần nhập ngày âm'; final parts = value.split('/'); if (parts.length != 3) return 'Nhập theo Ngày/Tháng/Năm'; @@ -277,10 +195,670 @@ class _MainState extends State { return null; } - void _handleSubmitted() { - final form = _formKey.currentState!; - if (form.validate()) { - form.save(); + String? _validateSolarDate(String? value) { + if (value == null || value.isEmpty) return 'Cần nhập ngày dương'; + final parts = value.split('/'); + if (parts.length != 3) return 'Nhập theo Ngày/Tháng/Năm'; + final day = int.tryParse(parts[0]); + final month = int.tryParse(parts[1]); + final year = int.tryParse(parts[2]); + if (day == null || month == null || year == null) { + return 'Nhập theo Ngày/Tháng/Năm'; + } + if (day < 1 || day > 31) return 'Ngày từ 1 đến 31'; + if (month < 1 || month > 12) return 'Tháng từ 1 đến 12'; + if (year < 1) return 'Năm không hợp lệ'; + return null; + } +} + +// --------------------------------------------------------------------------- +// Header +// --------------------------------------------------------------------------- +class _Header extends StatelessWidget { + const _Header({ + required this.onSearch, + required this.lunarFocus, + required this.onToggleFocus, + }); + + final VoidCallback onSearch; + final bool lunarFocus; + final VoidCallback onToggleFocus; + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Row( + children: [ + Icon( + Icons.dark_mode, + color: Theme.of(context).colorScheme.primary, + size: 28, + ), + const SizedBox(width: 8), + Text('Âm Lịch', style: Theme.of(context).textTheme.headlineMedium), + const Spacer(), + IconButton( + tooltip: lunarFocus ? 'Dương lịch' : 'Âm lịch', + icon: Icon(lunarFocus ? Icons.dark_mode : Icons.wb_sunny), + onPressed: onToggleFocus, + ), + IconButton( + tooltip: 'Hôm nay', + icon: const Icon(Icons.today), + onPressed: () => context.read().add(const TodaySelected()), + ), + IconButton( + tooltip: lunarFocus ? 'Tìm ngày Dương' : 'Tìm ngày Âm', + icon: const Icon(Icons.search), + onPressed: onSearch, + ), + IconButton( + tooltip: isDark ? 'Chế độ sáng' : 'Chế độ tối', + icon: Icon(isDark ? Icons.light_mode : Icons.dark_mode_outlined), + onPressed: () { + themeMode.value = isDark ? ThemeMode.light : ThemeMode.dark; + }, + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Today Card +// --------------------------------------------------------------------------- +class _TodayCard extends StatelessWidget { + const _TodayCard({ + required this.solar, + required this.lunar, + required this.bloc, + required this.lunarFocus, + }); + + final DateTime solar; + final Lunar lunar; + final MainBloc bloc; + final bool lunarFocus; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final accent = theme.colorScheme.secondary; + final isDark = theme.brightness == Brightness.dark; + final moonShadow = + isDark ? const Color(0xFF0F1B2D) : const Color(0xFFE5E7EB); + final moonColor = + isDark ? const Color(0xFFF5E6CA) : const Color(0xFF374151); + + final primaryTitle = lunarFocus ? '${lunar.day}' : '${solar.day}'; + final secondaryText = lunarFocus + ? '${bloc.weekFormat.format(solar)} • ' + '${solar.day}/${solar.month}/${solar.year}' + : 'Ngày ${lunar.day} ${lunar.monthString} • ' + 'Năm ${lunar.canchiYear}'; + + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () => bloc.add(const PreviousSelected()), + ), + Expanded( + child: Column( + children: [ + Text( + lunarFocus + ? '${lunar.monthString}' + : bloc.weekFormat.format(solar), + style: theme.textTheme.titleMedium, + ), + Text( + primaryTitle, + style: theme.textTheme.displayLarge, + ), + Text( + lunarFocus + ? 'Năm ${lunar.canchiYear}' + : bloc.monthYearFormat.format(solar).toUpperCase(), + style: theme.textTheme.titleMedium, + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: () => bloc.add(const NextSelected()), + ), + ], + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + decoration: BoxDecoration( + color: accent.withValues(alpha: isDark ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MoonPhaseIcon( + lunarDay: lunar.day, + color: moonColor, + shadowColor: moonShadow, + ), + const SizedBox(width: 12), + Flexible( + child: Text( + secondaryText, + style: theme.textTheme.bodyLarge?.copyWith( + color: accent, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Month Calendar +// --------------------------------------------------------------------------- +class _MonthCalendar extends StatelessWidget { + const _MonthCalendar({ + required this.selectedDate, + required this.selectedLunar, + required this.lunarFocus, + required this.onDateSelected, + }); + + final DateTime selectedDate; + final Lunar selectedLunar; + final bool lunarFocus; + final ValueChanged onDateSelected; + + static const _weekdays = ['T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'CN']; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final firstOfMonth = DateTime(selectedDate.year, selectedDate.month); + final daysInMonth = + DateTime(selectedDate.year, selectedDate.month + 1, 0).day; + final startWeekday = firstOfMonth.weekday; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + _buildMonthNav(theme), + const SizedBox(height: 8), + _buildWeekdayHeaders(theme), + const SizedBox(height: 4), + ..._buildWeeks( + context, + today, + firstOfMonth, + daysInMonth, + startWeekday, + ), + ], + ), + ), + ); + } + + Widget _buildMonthNav(ThemeData theme) { + final canChi = LunarConverter.getCanChiYear(selectedLunar.year); + final title = lunarFocus + ? '${selectedLunar.monthString} • $canChi' + : 'Tháng ${selectedDate.month}, ${selectedDate.year}'; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left, size: 20), + onPressed: () { + final prev = DateTime( + selectedDate.year, + selectedDate.month - 1, + selectedDate.day.clamp( + 1, + DateTime(selectedDate.year, selectedDate.month, 0).day, + ), + ); + onDateSelected(prev); + }, + ), + Flexible( + child: Text( + title, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right, size: 20), + onPressed: () { + final maxDay = DateTime( + selectedDate.year, + selectedDate.month + 2, + 0, + ).day; + final next = DateTime( + selectedDate.year, + selectedDate.month + 1, + selectedDate.day.clamp(1, maxDay), + ); + onDateSelected(next); + }, + ), + ], + ); + } + + Widget _buildWeekdayHeaders(ThemeData theme) { + return Row( + children: _weekdays.map((d) { + final isWeekend = d == 'T7' || d == 'CN'; + return Expanded( + child: Center( + child: Text( + d, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: isWeekend + ? theme.colorScheme.secondary.withValues(alpha: 0.7) + : null, + ), + ), + ), + ); + }).toList(), + ); + } + + List _buildWeeks( + BuildContext context, + DateTime today, + DateTime firstOfMonth, + int daysInMonth, + int startWeekday, + ) { + final theme = Theme.of(context); + final primary = theme.colorScheme.primary; + final accent = theme.colorScheme.secondary; + final isDark = theme.brightness == Brightness.dark; + final moonShadow = isDark ? const Color(0xFF162036) : Colors.white; + final moonColor = + isDark ? const Color(0xFFF5E6CA) : const Color(0xFF6B7280); + + final weeks = []; + var dayCounter = 1 - (startWeekday - 1); + + while (dayCounter <= daysInMonth) { + final cells = []; + for (var col = 0; col < 7; col++) { + final day = dayCounter; + dayCounter++; + + if (day < 1 || day > daysInMonth) { + cells.add(const Expanded(child: SizedBox(height: 60))); + continue; + } + + final date = DateTime(firstOfMonth.year, firstOfMonth.month, day); + final isToday = date == today; + final isSelected = date.year == selectedDate.year && + date.month == selectedDate.month && + date.day == selectedDate.day; + final lunarResult = LunarConverter.solarToLunar( + day, + firstOfMonth.month, + firstOfMonth.year, + ); + final lunarDay = lunarResult[0]; + final phase = moonPhaseFromLunarDay(lunarDay); + final isKeyPhase = phase == MoonPhaseType.newMoon || + phase == MoonPhaseType.fullMoon || + phase == MoonPhaseType.firstQuarter || + phase == MoonPhaseType.thirdQuarter; + final isWeekend = col >= 5; + + cells.add( + Expanded( + child: GestureDetector( + onTap: () => onDateSelected(date), + child: Container( + height: 60, + margin: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: isToday + ? primary + : isSelected + ? primary.withValues(alpha: 0.15) + : null, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Primary number: lunar day or solar day + Text( + lunarFocus ? '$lunarDay' : '$day', + style: TextStyle( + fontSize: lunarFocus ? 14 : 13, + fontWeight: + isSelected ? FontWeight.w700 : FontWeight.w500, + color: isToday + ? theme.colorScheme.onPrimary + : isWeekend + ? accent.withValues(alpha: 0.8) + : theme.textTheme.bodyLarge?.color, + ), + ), + const SizedBox(height: 2), + // Secondary: moon icon or the other day number + if (isKeyPhase && lunarFocus) + MoonPhaseIcon( + lunarDay: lunarDay, + size: 14, + color: + isToday ? theme.colorScheme.onPrimary : moonColor, + shadowColor: isToday ? primary : moonShadow, + ) + else + Text( + lunarFocus ? '$day' : '$lunarDay', + style: TextStyle( + fontSize: 10, + color: isToday + ? theme.colorScheme.onPrimary + .withValues(alpha: 0.7) + : theme.textTheme.bodyMedium?.color, + ), + ), + ], + ), + ), + ), + ), + ); + } + weeks.add(Row(children: cells)); } + return weeks; + } +} + +// --------------------------------------------------------------------------- +// Info Panel +// --------------------------------------------------------------------------- +class _InfoPanel extends StatelessWidget { + const _InfoPanel({required this.lunar}); + + final Lunar lunar; + + static const _hourEmoji = [ + '🐀', + '🐂', + '🐅', + '🐇', + '🐉', + '🐍', + '🐴', + '🐐', + '🐒', + '🐓', + '🐕', + '🐖', + ]; + + static const _hourLabels = [ + 'Tí', + 'Sửu', + 'Dần', + 'Mão', + 'Thìn', + 'Tỵ', + 'Ngọ', + 'Mùi', + 'Thân', + 'Dậu', + 'Tuất', + 'Hợi', + ]; + + static const _hourTimes = [ + '23-1', + '1-3', + '3-5', + '5-7', + '7-9', + '9-11', + '11-13', + '13-15', + '15-17', + '17-19', + '19-21', + '21-23', + ]; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final primary = theme.colorScheme.primary; + final hours = lunar.hours ?? []; + final moonColor = + isDark ? const Color(0xFFF5E6CA) : const Color(0xFF374151); + final moonShadow = + isDark ? const Color(0xFF0F1B2D) : const Color(0xFFE5E7EB); + + final phase = moonPhaseFromLunarDay(lunar.day); + final phaseLabel = moonPhaseLabel(phase); + + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _infoTile( + context, + icon: MoonPhaseIcon( + lunarDay: lunar.day, + size: 36, + color: moonColor, + shadowColor: moonShadow, + ), + label: phaseLabel.isNotEmpty + ? phaseLabel.replaceAll('\n', ' ') + : 'Ngày ${lunar.day}', + labelColor: primary, + ), + _infoTile( + context, + title: 'Ngày', + value: lunar.canChiDay ?? '', + ), + _infoTile( + context, + title: 'Tháng', + value: lunar.canChiMonth ?? '', + ), + _infoTile( + context, + title: 'Năm', + value: lunar.canchiYear ?? '', + ), + ], + ), + const SizedBox(height: 20), + Text( + 'Giờ Hoàng Đạo', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 10), + _buildHoursGrid(theme, hours, primary), + ], + ), + ), + ); + } + + Widget _infoTile( + BuildContext context, { + Widget? icon, + String? title, + String? value, + String? label, + Color? labelColor, + }) { + final theme = Theme.of(context); + return Expanded( + child: Column( + children: [ + if (icon != null) icon, + if (title != null) + Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + if (value != null) + Text( + value, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + textAlign: TextAlign.center, + ), + if (label != null) + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: labelColor, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildHoursGrid( + ThemeData theme, + List activeHours, + Color primary, + ) { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 6, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + ), + itemCount: 12, + itemBuilder: (context, i) { + final isActive = activeHours.contains(LunarConverter.hourNames[i]); + + return Container( + decoration: BoxDecoration( + color: + isActive ? primary.withValues(alpha: 0.15) : Colors.transparent, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isActive + ? primary.withValues(alpha: 0.4) + : theme.textTheme.bodyMedium!.color!.withValues(alpha: 0.15), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _hourEmoji[i], + style: TextStyle( + fontSize: 18, + color: isActive ? null : Colors.grey, + ), + ), + const SizedBox(height: 2), + Text( + _hourLabels[i], + style: TextStyle( + fontSize: 10, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, + color: isActive ? primary : theme.textTheme.bodyMedium?.color, + ), + ), + Text( + '${_hourTimes[i]}h', + style: TextStyle( + fontSize: 9, + color: theme.textTheme.bodyMedium?.color, + ), + ), + ], + ), + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Footer +// --------------------------------------------------------------------------- +class _Footer extends StatelessWidget { + @override + Widget build(BuildContext context) { + 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', + ), + ), + const SizedBox(width: 8), + TextButton.icon( + icon: const Icon(CustomIcons.githubCircled, size: 16), + label: const Text('GitHub'), + onPressed: () => open('https://github.com/de-men/amlich'), + ), + ], + ); } } diff --git a/lib/widget/moon_phase.dart b/lib/widget/moon_phase.dart new file mode 100644 index 0000000..3bec94b --- /dev/null +++ b/lib/widget/moon_phase.dart @@ -0,0 +1,235 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// Lunar day labels for key phases. +enum MoonPhaseType { + newMoon, + waxingCrescent, + firstQuarter, + waxingGibbous, + fullMoon, + waningGibbous, + thirdQuarter, + waningCrescent +} + +MoonPhaseType moonPhaseFromLunarDay(int lunarDay) { + if (lunarDay == 1) return MoonPhaseType.newMoon; + if (lunarDay >= 2 && lunarDay <= 6) return MoonPhaseType.waxingCrescent; + if (lunarDay >= 7 && lunarDay <= 9) return MoonPhaseType.firstQuarter; + if (lunarDay >= 10 && lunarDay <= 14) return MoonPhaseType.waxingGibbous; + if (lunarDay == 15) return MoonPhaseType.fullMoon; + if (lunarDay >= 16 && lunarDay <= 20) return MoonPhaseType.waningGibbous; + if (lunarDay >= 21 && lunarDay <= 23) return MoonPhaseType.thirdQuarter; + return MoonPhaseType.waningCrescent; +} + +String moonPhaseLabel(MoonPhaseType type) { + return switch (type) { + MoonPhaseType.newMoon => 'Sóc', + MoonPhaseType.firstQuarter => 'Thượng\nhuyền', + MoonPhaseType.fullMoon => 'Vọng', + MoonPhaseType.thirdQuarter => 'Hạ\nhuyền', + _ => '', + }; +} + +/// Widget that draws a moon phase based on the lunar day (1-30). +class MoonPhaseIcon extends StatelessWidget { + const MoonPhaseIcon({ + required this.lunarDay, + super.key, + this.size = 24, + this.color, + this.shadowColor, + }); + + final int lunarDay; + final double size; + final Color? color; + final Color? shadowColor; + + @override + Widget build(BuildContext context) { + final moonColor = color ?? const Color(0xFFF5E6CA); + final shadow = shadowColor ?? const Color(0xFF1A1A2E); + + return SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: _MoonPhasePainter( + lunarDay: lunarDay, + moonColor: moonColor, + shadowColor: shadow, + ), + ), + ); + } +} + +class _MoonPhasePainter extends CustomPainter { + _MoonPhasePainter({ + required this.lunarDay, + required this.moonColor, + required this.shadowColor, + }); + + final int lunarDay; + final Color moonColor; + final Color shadowColor; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2; + + // Draw the full moon circle (lit side) + final basePaint = Paint()..color = moonColor; + canvas.drawCircle(center, radius, basePaint); + + // Calculate illumination: 0 at day 1 (new), 1 at day 15 (full) + // Using a simple cosine curve for smooth transition + final phase = (lunarDay - 1) / 29.5; + final illumination = (1 - math.cos(phase * 2 * math.pi)) / 2; + + final shadowPaint = Paint()..color = shadowColor; + + if (illumination < 0.5) { + _paintLessHalf( + canvas, + center, + radius, + size, + shadowPaint, + basePaint, + illumination, + ); + } else { + _paintMoreHalf( + canvas, + center, + radius, + size, + shadowPaint, + basePaint, + illumination, + ); + } + } + + void _paintLessHalf( + Canvas canvas, + Offset center, + double radius, + Size size, + Paint shadowPaint, + Paint basePaint, + double illumination, + ) { + final shadowWidth = radius * (1 - illumination * 2); + final isWaxing = lunarDay <= 15; + + final path = Path() + ..addOval(Rect.fromCircle(center: center, radius: radius)) + ..moveTo(center.dx, center.dy - radius); + _addHalfEllipse( + path, + center, + radius, + shadowWidth, + isRight: isWaxing, + ); + path.close(); + + final clipRight = Rect.fromLTWH(center.dx, 0, radius, size.height); + final clipLeft = Rect.fromLTWH(0, 0, center.dx, size.height); + + final litPath = Path()..moveTo(center.dx, center.dy - radius); + _addHalfEllipse( + litPath, + center, + radius, + shadowWidth, + isRight: !isWaxing, + ); + litPath.close(); + + canvas + ..save() + ..clipRect(isWaxing ? clipRight : clipLeft) + ..drawPath(path, shadowPaint) + ..restore() + ..save() + ..clipRect(isWaxing ? clipLeft : clipRight) + ..drawCircle(center, radius, shadowPaint) + ..restore() + ..save() + ..clipRect(isWaxing ? clipLeft : clipRight) + ..drawPath(litPath, basePaint) + ..restore(); + } + + void _paintMoreHalf( + Canvas canvas, + Offset center, + double radius, + Size size, + Paint shadowPaint, + Paint basePaint, + double illumination, + ) { + final litWidth = radius * ((illumination - 0.5) * 2); + final isWaxing = lunarDay <= 15; + + final clipShadow = isWaxing + ? Rect.fromLTWH(0, 0, center.dx, size.height) + : Rect.fromLTWH(center.dx, 0, radius, size.height); + + final litPath = Path()..moveTo(center.dx, center.dy - radius); + _addHalfEllipse( + litPath, + center, + radius, + litWidth, + isRight: !isWaxing, + ); + litPath.close(); + + canvas + ..save() + ..clipRect(clipShadow) + ..drawCircle(center, radius, shadowPaint) + ..restore() + ..save() + ..clipRect(clipShadow) + ..drawPath(litPath, basePaint) + ..restore(); + } + + void _addHalfEllipse( + Path path, + Offset center, + double radius, + double ellipseWidth, { + required bool isRight, + }) { + final rect = Rect.fromCenter( + center: center, + width: ellipseWidth * 2, + height: radius * 2, + ); + if (isRight) { + path.arcTo(rect, -math.pi / 2, math.pi, false); + } else { + path.arcTo(rect, math.pi / 2, math.pi, false); + } + } + + @override + bool shouldRepaint(_MoonPhasePainter oldDelegate) => + lunarDay != oldDelegate.lunarDay || + moonColor != oldDelegate.moonColor || + shadowColor != oldDelegate.shadowColor; +}