import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:flutter/material.dart'; import 'package:lunar/lunar.dart'; import '../../../core/utils/logger.dart'; import '../../../data/models/assistant_directive.dart'; import '../../../data/models/memory_item.dart'; import '../../../data/services/assistant_directive_service.dart'; import '../../../data/services/memory_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)。 /// /// 几何/手势/折叠/翻页逻辑原样保留;**数据层已换成服务端**—— /// 唯一来源是 [MemoryService.items],本地 GetStorage 只做缓存与离线兜底。 /// 演示数据 `mockCalendarEvents()` 已删除:真实数据上线后混着 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? _memoryWorker; Worker? _reminderWorker; // 折叠态横向条的代办/花销筛选:null=全部,非空=只看该类别。 // 点击"代办"/"花销"标签设置;点击日期(onDaySelected)重置为全部。 final Rx categoryFilter = Rx(null); /// 设置分类筛选,null = 全部。 /// /// 与 [setCategoryFilter] 分开:那个是「点某一类」的语义(同类再点一次会取消), /// 这个是筛选条直接指定,包含「全部」这一档。 void setCategoryFilterOrAll(CalendarEventCategory? category) { categoryFilter.value = category; } 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); _rebuildEvents(); // 服务端数据变了(拉取完成、用户增删改、EMAI 落了新闹钟)就立刻重投影, // 不用退出重进页面。 if (Get.isRegistered()) { _memoryWorker = ever>( MemoryService.to.items, (_) => _rebuildEvents(), ); } // 迁移期还要盯着本地那份:服务端没收下时(后端未部署 / 断网), // 助手刚定的闹钟只存在于本地 reminders 里,不订阅它页面就不会刷新。 if (Get.isRegistered()) { _reminderWorker = ever>( AssistantDirectiveService.to.reminders, (_) => _rebuildEvents(), ); } } /// 从 [MemoryService] 单一来源投影成日历事件。 /// /// 重复项在服务端是**一行记录**(repeat_rule=daily/weekly…),日历要按天展示, /// 所以在可见窗口内逐日摊开——这段展开逻辑是从原来的助手提醒那套留用的, /// 只是输入从 AssistantReminder 换成了 MemoryItem。 void _rebuildEvents() { if (!Get.isRegistered()) { events.clear(); return; } final list = []; for (final item in MemoryService.to.items) { list.addAll(_expandItem(item)); } list.addAll(_localReminderEvents()); events.assignAll(list); } /// 迁移期的本地兜底:把**还没同步到服务端**的助手闹钟也投影出来。 /// /// ⚠️ 不做这个,「双写」就是假的。SET_clock 会把闹钟写进本地 reminders, /// 但换源之后页面只读 MemoryService —— 后端没部署或断网时 memory_add 失败, /// 本地那份没有任何展示出口,用户对助手说完「定个会议提醒」, /// 助手答应了、指令也落地了,拾忆页却空空如也(真机实测踩到)。 /// /// 已同步的跳过:那时 MemoryService 里的才是权威,两边都投影会出现两条。 List _localReminderEvents() { if (!Get.isRegistered()) return const []; final out = []; for (final r in AssistantDirectiveService.to.reminders) { if (r.synced) continue; // id 前缀区分来源:本地项的 memoryId 是 0,点开编辑时要能识别出 // 「这条还没上服务端」而不是去 MemoryService 里查一个不存在的 id。 out.addAll(_expandItem(r.toMemoryItem(), idPrefix: 'local:${r.id}:')); } return out; } /// 把一条记忆项摊成日历事件。 /// /// 窗口取「今天前 7 天 ~ 后 60 天」:往前留几天是为了让用户还能看到刚过去的 /// 事项,往后 60 天覆盖月视图翻两页。 List _expandItem(MemoryItem item, {String idPrefix = ''}) { final anchor = item.happenDateTime; final cat = CalendarEventCategoryX.fromRaw(item.category); final color = MemoryCategory.colorOf(item.category); CalendarEvent make(DateTime day, String suffix) => CalendarEvent( id: '$idPrefix${item.id}$suffix', memoryId: item.id, title: item.title, // 负责人与截止原文合成一行副标题;两者都空时留空而不是塞占位符 description: [item.owner, item.dueRaw] .where((e) => e.isNotEmpty) .join(' · '), date: DateTime(day.year, day.month, day.day), startTime: item.startTime, location: '—', tag: MemoryCategory.labelKey(item.category).tr, color: color, taskStatus: item.isDone ? 2 : 0, category: cat, // 展示层用「元」,存储与传输一律用「分」 amount: item.isExpense ? item.amountYuan : null, dateCertain: item.dateCertain, dueRaw: item.dueRaw, repeating: item.isRepeating, ); if (!item.isRepeating) { return [make(anchor, '')]; } 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)); // 重复项在锚点日之前不该出现——去年设的闹钟不应该显示在前年 if (day.isBefore(DateTime(anchor.year, anchor.month, anchor.day))) continue; final hit = switch (item.repeatRule) { MemoryRepeat.daily => true, // weekday 用 1-7(周一~周日),与 Dart 的 DateTime.weekday 一致。 // ⚠️ 服务端也是这个口径(Go 的 time.Weekday 周日是 0,那边已做换算)。 MemoryRepeat.weekly => day.weekday == item.weekday, MemoryRepeat.weekdays => day.weekday <= DateTime.friday, MemoryRepeat.weekend => day.weekday == DateTime.saturday || day.weekday == DateTime.sunday, MemoryRepeat.monthly => day.day == anchor.day, _ => false, }; if (hit) out.add(make(day, '@${day.year}${day.month}${day.day}')); } return out; } /// 拉取当前可见窗口的数据。下拉刷新与首次进入都走它。 Future refreshFromServer({bool silent = false}) async { if (!Get.isRegistered()) return; // 先把本地没同步上去的闹钟补传,再拉——顺序反了的话刚补传的这次拉不到, // 要等下一次刷新才显示。 if (Get.isRegistered()) { await AssistantDirectiveService.to.syncPending(); } final today = DateTime.now(); await MemoryService.to.load( start: today.subtract(const Duration(days: 30)), end: today.add(const Duration(days: 60)), silent: silent, ); Logger.d('CalendarController', '刷新完成,共 ${events.length} 个日历事件'); } @override void onClose() { _memoryWorker?.dispose(); _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); } /// 回到今天(替代参考里的"今天"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 = 全部 /// /// ⚠️ 按值相等筛而不是 switch 逐个枚举:分类会随服务端增加(未知分类会落到 /// [CalendarEventCategory.other]),穷举式 switch 每加一类都得回来改一次, /// 漏改就是编译错——而这一层根本不关心具体是哪一类。 List get visibleEventsForSelectedDate { final f = categoryFilter.value; if (f == null) return eventsForSelectedDate; return eventsForSelectedDate.where((e) => e.category == f).toList(); } /// 选中日期的花销合计(元) 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); } /// 当前范围内的全部事件,按日期升序(同一天内保持原顺序) /// 当前范围内、且通过分类筛选的事项。 /// /// ⚠️ **必须读 categoryFilter**:拾忆页顶部的分类筛选条改的就是它。 /// 早先这里只按日期过滤,于是筛选条点了没有任何反应——值改了但没人读。 List get eventsForScope { final f = categoryFilter.value; final list = events .where((e) => _inScope(e.date) && (f == null || e.category == f)) .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]; } }