import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:flutter/material.dart'; import 'package:lunar/lunar.dart'; import '../../../data/models/assistant_directive.dart'; import '../../../data/services/assistant_directive_service.dart'; import '../views/widgets/calendar/calendar_event.dart'; /// 日历视图格式:月视图 / 周视图 enum _CalendarFormat { month, week } /// 代办列表的统计范围:当天 / 本周 / 当月 enum CalendarScope { day('scopeDay'), week('scopeWeek'), month('scopeMonth'); const CalendarScope(this.label); /// i18n key,展示处 `.tr` final String label; } /// 首页日历面板控制器(移植自 AI-study-machine 的 CalendarController)。 /// /// 几何/手势/折叠/翻页逻辑原样保留;数据层换成内存 mock(不接后端/不落库)。 class CalendarController extends GetxController with GetSingleTickerProviderStateMixin { // ==================== Rx 状态 ==================== // 视图格式(月 / 周),默认周视图(折叠) final calendarFormat = _CalendarFormat.week.obs; // 焦点月份的 1 号(月 PageView 的页索引基准) // 不变量:focusedDay 总等于 DateTime(selectedDate.year, selectedDate.month, 1) final focusedDay = DateTime(DateTime.now().year, DateTime.now().month, 1).obs; // 当前选中日期 final selectedDate = DateTime.now().obs; // 拖拽进度:0.0(月视图)→ 1.0(周视图)。默认 1.0:启动即为折叠的周视图 final dragProgress = 1.0.obs; // 事件列表(mock) final RxList events = [].obs; /// 监听助手提醒变化的 worker,onClose 要停掉 Worker? _reminderWorker; // 折叠态横向条的代办/花销筛选:null=全部,非空=只看该类别。 // 点击"代办"/"花销"标签设置;点击日期(onDaySelected)重置为全部。 final Rx categoryFilter = Rx(null); void setCategoryFilter(CalendarEventCategory category) { categoryFilter.value = category; } // ==================== 非响应式字段 ==================== // PageView 基准日期(启动时锁定) late final DateTime _baseDay; DateTime get _baseMonth => DateTime(_baseDay.year, _baseDay.month, 1); DateTime get _baseWeek => _mondayOf(_baseDay); static const int _monthInitialPage = 1200; // 前后各 100 年 static const int _weekInitialPage = 5200; // 前后各 100 年 × 52 周 late final PageController monthPageController; late final PageController weekPageController; late final AnimationController _snapAnimation; // 拖拽累计状态 double _dragStartProgress = 0; // snap 动画的 from/to/target double _pendingSnapFrom = 0; double _pendingSnapTo = 0; _CalendarFormat _pendingSnapTarget = _CalendarFormat.month; // 手势阈值 static const double _flingVelocityThreshold = 500; // 动画时长上下限 static const int _snapDurationMaxMs = 260; static const int _snapDurationMinMs = 120; // ==================== 生命周期 ==================== @override void onInit() { super.onInit(); _baseDay = DateTime.now(); monthPageController = PageController(initialPage: _monthInitialPage); weekPageController = PageController(initialPage: _weekInitialPage); _snapAnimation = AnimationController( vsync: this, duration: const Duration(milliseconds: _snapDurationMaxMs), )..addListener(_onSnapAnimationTick); // 加载内存 mock 数据(不接后端/不落库) _rebuildEvents(); // EMAI 助手定的闹钟落地后要立刻出现在这里,不用退出重进 if (Get.isRegistered()) { _reminderWorker = ever>( AssistantDirectiveService.to.reminders, (_) => _rebuildEvents(), ); } } /// mock 演示数据 + EMAI 助手落下来的提醒 void _rebuildEvents() { final list = [...mockCalendarEvents()]; if (Get.isRegistered()) { for (final r in AssistantDirectiveService.to.reminders) { list.addAll(_expandReminder(r)); } } events.assignAll(list); } /// 把一条提醒摊成日历事件。 /// /// 重复提醒在协议里只有一条记录(`repeat=每天` / `周一`),日历要按天展示, /// 所以在可见窗口内逐日摊开。窗口取「今天前 7 天 ~ 后 60 天」:往前留几天是 /// 为了让用户还能看到刚过去的提醒,往后 60 天覆盖月视图翻两页。 List _expandReminder(AssistantReminder r) { final title = r.content.isNotEmpty ? r.content : 'alarm'.tr; final start = TimeOfDay(hour: r.hour, minute: r.minute); CalendarEvent make(DateTime day, String suffix) => CalendarEvent( id: '${r.id}$suffix', title: title, // 重复规则原文放描述里,用户能看懂"每周一"比看懂 weekly 容易 description: r.repeatRaw, date: DateTime(day.year, day.month, day.day), startTime: start, location: '—', tag: 'demoTagReminder'.tr, color: const Color(0xFF22C1A6), taskStatus: r.done ? 2 : 0, ); // 一次性:只在指定那天(没给 date 就算今天) if (r.repeat == RepeatRule.once || r.repeat == RepeatRule.unknown) { final day = r.date != null ? DateTime.tryParse(r.date!) : null; return [make(day ?? DateTime.now(), '')]; } final today = DateTime.now(); final from = DateTime(today.year, today.month, today.day) .subtract(const Duration(days: 7)); final out = []; for (var i = 0; i < 67; i++) { final day = from.add(Duration(days: i)); final hit = switch (r.repeat) { RepeatRule.daily => true, RepeatRule.weekly => day.weekday == r.weekday, RepeatRule.weekdays => day.weekday <= DateTime.friday, RepeatRule.weekend => day.weekday == DateTime.saturday || day.weekday == DateTime.sunday, _ => false, }; if (hit) out.add(make(day, '@${day.year}${day.month}${day.day}')); } return out; } @override void onClose() { _reminderWorker?.dispose(); _snapAnimation.dispose(); monthPageController.dispose(); weekPageController.dispose(); super.onClose(); } // ==================== 拖拽 ==================== /// 垂直拖拽开始 void onDragStart() { if (_snapAnimation.isAnimating) _snapAnimation.stop(); _dragStartProgress = dragProgress.value; // 若当前是周视图即将扩展:预先把 monthPageController 同步到 focusedDay 所在月 if (calendarFormat.value == _CalendarFormat.week && monthPageController.hasClients) { monthPageController.jumpToPage(monthPageIndexFromDate(focusedDay.value)); } } /// 垂直拖拽过程中:按位移连续更新 dragProgress /// [dy] 帧增量 Y;[dragSpan] 月视图高度 - 周视图高度(总拖拽距离) void onDragUpdate(double dy, double dragSpan) { // dy 正 = 往下拖 = progress 减小(朝月视图) // dy 负 = 往上拖 = progress 增大(朝周视图) final delta = -dy / dragSpan; dragProgress.value = (_dragStartProgress + delta).clamp(0.0, 1.0); _dragStartProgress = dragProgress.value; } /// 垂直拖拽结束:按速度或位置决定 snap 方向 void onDragEnd(double velocityY) { final current = dragProgress.value; _CalendarFormat target; if (velocityY.abs() > _flingVelocityThreshold) { target = velocityY < 0 ? _CalendarFormat.week : _CalendarFormat.month; } else { target = current >= 0.5 ? _CalendarFormat.week : _CalendarFormat.month; } _snapToFormat(target); } // ==================== Snap 动画 ==================== void _snapToFormat(_CalendarFormat target) { final targetProgress = target == _CalendarFormat.week ? 1.0 : 0.0; final from = dragProgress.value; _pendingSnapFrom = from; _pendingSnapTo = targetProgress; _pendingSnapTarget = target; final distance = (targetProgress - from).abs(); final ms = (_snapDurationMaxMs * distance).round().clamp( _snapDurationMinMs, _snapDurationMaxMs, ); _snapAnimation ..reset() ..duration = Duration(milliseconds: ms) ..forward(); } void _onSnapAnimationTick() { final curved = Curves.easeOutCubic.transform(_snapAnimation.value); dragProgress.value = _lerp(_pendingSnapFrom, _pendingSnapTo, curved); if (_snapAnimation.isCompleted) { if (calendarFormat.value != _pendingSnapTarget) { calendarFormat.value = _pendingSnapTarget; _syncOtherPageController(); } } } void _syncOtherPageController() { if (calendarFormat.value == _CalendarFormat.week) { if (weekPageController.hasClients) { weekPageController .jumpToPage(weekPageIndexFromDate(selectedDate.value)); } } else { if (monthPageController.hasClients) { monthPageController .jumpToPage(monthPageIndexFromDate(focusedDay.value)); } } } double _lerp(double a, double b, double t) => a + (b - a) * t; // ==================== PageView 翻页回调 ==================== void onMonthPageChanged(int pageIndex) { final newMonth = monthFromPageIndex(pageIndex); focusedDay.value = newMonth; selectedDate.value = newMonth; // 维护不变量:回到新月 1 号 } void onWeekPageChanged(int pageIndex) { final newMonday = weekMondayFromPageIndex(pageIndex); // 保持用户选中的是周几 final oldSel = selectedDate.value; final dayOffsetInWeek = (oldSel.weekday - 1).clamp(0, 6); final newSelected = newMonday.add(Duration(days: dayOffsetInWeek)); selectedDate.value = newSelected; focusedDay.value = DateTime(newSelected.year, newSelected.month, 1); } // ==================== 日期选择 ==================== /// 用户点击某日(月视图或周视图):切换日期后重置代办/花销筛选,显示全部 void onDaySelected(DateTime day) { selectedDate.value = day; focusedDay.value = DateTime(day.year, day.month, 1); categoryFilter.value = null; } /// 回到今天(替代参考里的"今天"FAB) void jumpToToday() { final today = DateTime.now(); final targetMonth = DateTime(today.year, today.month, 1); if (isWeekView) { if (weekPageController.hasClients) { weekPageController.jumpToPage(weekPageIndexFromDate(today)); } } else { if (monthPageController.hasClients) { monthPageController.jumpToPage(monthPageIndexFromDate(targetMonth)); } } // jumpToPage 会触发 onPageChanged 把 selectedDate 置为首日,故下一帧纠正为今天 WidgetsBinding.instance.addPostFrameCallback((_) { selectedDate.value = today; focusedDay.value = targetMonth; }); } // ==================== 查询/派生 ==================== List get eventsForSelectedDate => events .where((event) => DateUtils.isSameDay(event.date, selectedDate.value)) .toList(); /// 选中日期的代办事项(代办表) List get todosForSelectedDate => eventsForSelectedDate .where((event) => event.category == CalendarEventCategory.todo) .toList(); /// 选中日期的花销记录(花销表) List get expensesForSelectedDate => eventsForSelectedDate .where((event) => event.category == CalendarEventCategory.expense) .toList(); /// 折叠态横向条实际展示的条目:按 [categoryFilter] 筛选,null 时代办+花销都显示 List get visibleEventsForSelectedDate { switch (categoryFilter.value) { case CalendarEventCategory.todo: return todosForSelectedDate; case CalendarEventCategory.expense: return expensesForSelectedDate; case null: return eventsForSelectedDate; } } /// 选中日期的花销合计(元) double get totalExpenseForSelectedDate => expensesForSelectedDate .fold(0, (sum, event) => sum + (event.amount ?? 0)); String get selectedDateLabel => '${selectedDate.value.month}月${selectedDate.value.day}日'; // ==================== 日/周/月范围 ==================== /// 代办列表当前的统计范围,默认「日」 final scope = CalendarScope.day.obs; void setScope(CalendarScope value) => scope.value = value; /// 当前范围的起止日期(含首尾,都归一到 0 点) ({DateTime start, DateTime end}) get scopeRange { final sel = DateTime( selectedDate.value.year, selectedDate.value.month, selectedDate.value.day); switch (scope.value) { case CalendarScope.day: return (start: sel, end: sel); case CalendarScope.week: final monday = _mondayOf(sel); return (start: monday, end: monday.add(const Duration(days: 6))); case CalendarScope.month: final first = DateTime(sel.year, sel.month, 1); // 下个月 0 号 = 本月最后一天,自动处理大小月和闰年 final last = DateTime(sel.year, sel.month + 1, 0); return (start: first, end: last); } } /// 范围标题,如「8月17日」「8月11日 - 8月17日」「2026年8月」 String get scopeLabel { final r = scopeRange; // 日期格式跟随当前语言:中文「8月17日」,英文「Aug 17」,西语「17 ago」 final String tag = (Get.locale ?? const Locale('zh', 'CN')).toLanguageTag(); final md = DateFormat.MMMd(tag); final ym = DateFormat.yMMMM(tag); switch (scope.value) { case CalendarScope.day: return md.format(r.start); case CalendarScope.week: return '${md.format(r.start)} - ${md.format(r.end)}'; case CalendarScope.month: return ym.format(r.start); } } bool _inScope(DateTime d) { final r = scopeRange; final day = DateTime(d.year, d.month, d.day); return !day.isBefore(r.start) && !day.isAfter(r.end); } /// 当前范围内的全部事件,按日期升序(同一天内保持原顺序) List get eventsForScope { final list = events.where((e) => _inScope(e.date)).toList(); list.sort((a, b) => a.date.compareTo(b.date)); return list; } List get todosForScope => eventsForScope .where((e) => e.category == CalendarEventCategory.todo) .toList(); List get expensesForScope => eventsForScope .where((e) => e.category == CalendarEventCategory.expense) .toList(); double get totalExpenseForScope => expensesForScope.fold(0, (sum, e) => sum + (e.amount ?? 0)); /// 按天分组(周/月视图下列表要按日期分节展示)。 /// key 是归一到 0 点的日期,已按时间升序。 List<({DateTime day, List events})> get eventsGroupedByDay { final map = >{}; for (final e in eventsForScope) { final key = DateTime(e.date.year, e.date.month, e.date.day); map.putIfAbsent(key, () => []).add(e); } final keys = map.keys.toList()..sort(); return keys .map((k) => (day: k, events: map[k]!)) .toList(growable: false); } bool hasEventOn(DateTime day) => events.any((event) => DateUtils.isSameDay(event.date, day)); /// 当前是否为周视图 bool get isWeekView => calendarFormat.value == _CalendarFormat.week; // ==================== 工具方法 ==================== /// 某天所在周的周一 DateTime _mondayOf(DateTime d) { return DateTime(d.year, d.month, d.day) .subtract(Duration(days: d.weekday - 1)); } /// 月 PageView 页索引 → 该月 1 号 DateTime monthFromPageIndex(int pageIndex) { final diff = pageIndex - _monthInitialPage; return DateTime(_baseMonth.year, _baseMonth.month + diff, 1); } /// 日期 → 月 PageView 页索引 int monthPageIndexFromDate(DateTime date) { return _monthInitialPage + (date.year - _baseMonth.year) * 12 + (date.month - _baseMonth.month); } /// 周 PageView 页索引 → 该周的周一 DateTime weekMondayFromPageIndex(int pageIndex) { final diff = pageIndex - _weekInitialPage; return _baseWeek.add(Duration(days: diff * 7)); } /// 日期 → 周 PageView 页索引(基于该日期所在周的周一) int weekPageIndexFromDate(DateTime date) { final targetMonday = _mondayOf(date); final diffDays = targetMonday.difference(_baseWeek).inDays; return _weekInitialPage + diffDays ~/ 7; } /// 月视图下选中日期所在 6-行布局的 Y 偏移(像素) double selectedRowYOffset(double rowHeightWithSpacing) { final sel = selectedDate.value; final firstDay = DateTime(sel.year, sel.month, 1); final startOffset = (firstDay.weekday + 6) % 7; // 周一=0..周日=6 final dayInGrid = sel.day + startOffset - 1; // 0..41 final rowIndex = dayInGrid ~/ 7; return rowIndex * rowHeightWithSpacing; } /// 给 View 用:指定月份的 42 天列表(含前后月占位) List daysForMonth(DateTime month) { final firstDay = DateTime(month.year, month.month, 1); final startOffset = (firstDay.weekday + 6) % 7; final startDate = firstDay.subtract(Duration(days: startOffset)); return List.generate( 42, (index) => DateTime(startDate.year, startDate.month, startDate.day + index), ); } // ==================== 农历/节日 ==================== /// 获取某日期的农历或节日文本(优先显示节日) /// 当前语言是不是中文。农历、节气、干支这些只有中文语境下才有意义, /// 切到英语/西语时直接不显示,比硬塞一串中文好。 static bool get isChineseLocale => (Get.locale?.languageCode ?? 'zh').toLowerCase().startsWith('zh'); String getLunarOrFestival(DateTime date) { if (!isChineseLocale) return ''; try { final lunar = Lunar.fromDate(date); final solarFestival = _getSolarFestival(date.month, date.day); if (solarFestival != null) return solarFestival; final lunarFestival = _getLunarFestival(lunar.getMonth(), lunar.getDay()); if (lunarFestival != null) return lunarFestival; final jieQi = lunar.getJieQi(); if (jieQi.isNotEmpty) return jieQi; return _formatLunarDay(lunar); } catch (e) { return ''; } } String _formatLunarDay(Lunar lunar) { final day = lunar.getDay(); if (day == 1) { return '${lunar.getMonthInChinese()}月'; } return lunar.getDayInChinese(); } String? _getSolarFestival(int month, int day) { final key = '$month-$day'; const festivals = { '1-1': '元旦', '2-14': '情人节', '3-8': '妇女节', '3-12': '植树节', '4-1': '愚人节', '5-1': '劳动节', '5-4': '青年节', '6-1': '儿童节', '7-1': '建党节', '8-1': '建军节', '9-10': '教师节', '10-1': '国庆节', '12-24': '平安夜', '12-25': '圣诞节', }; return festivals[key]; } String? _getLunarFestival(int month, int day) { final key = '$month-$day'; const festivals = { '1-1': '春节', '1-15': '元宵节', '2-2': '龙抬头', '5-5': '端午节', '7-7': '七夕节', '7-15': '中元节', '8-15': '中秋节', '9-9': '重阳节', '12-8': '腊八节', '12-23': '小年', }; return festivals[key]; } }