You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

636 lines
23 KiB

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<CalendarEvent> events = <CalendarEvent>[].obs;
/// 监听助手提醒变化的 worker,onClose 要停掉
Worker? _memoryWorker;
Worker? _reminderWorker;
// 折叠态横向条的代办/花销筛选:null=全部,非空=只看该类别。
// 点击"代办"/"花销"标签设置;点击日期(onDaySelected)重置为全部。
final Rx<CalendarEventCategory?> categoryFilter =
Rx<CalendarEventCategory?>(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<MemoryService>()) {
_memoryWorker = ever<List<MemoryItem>>(
MemoryService.to.items,
(_) => _rebuildEvents(),
);
}
// 迁移期还要盯着本地那份:服务端没收下时(后端未部署 / 断网),
// 助手刚定的闹钟只存在于本地 reminders 里,不订阅它页面就不会刷新。
if (Get.isRegistered<AssistantDirectiveService>()) {
_reminderWorker = ever<List<AssistantReminder>>(
AssistantDirectiveService.to.reminders,
(_) => _rebuildEvents(),
);
}
}
/// 从 [MemoryService] 单一来源投影成日历事件。
///
/// 重复项在服务端是**一行记录**(repeat_rule=daily/weekly…),日历要按天展示,
/// 所以在可见窗口内逐日摊开——这段展开逻辑是从原来的助手提醒那套留用的,
/// 只是输入从 AssistantReminder 换成了 MemoryItem。
void _rebuildEvents() {
if (!Get.isRegistered<MemoryService>()) {
events.clear();
return;
}
final list = <CalendarEvent>[];
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<CalendarEvent> _localReminderEvents() {
if (!Get.isRegistered<AssistantDirectiveService>()) return const [];
final out = <CalendarEvent>[];
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<CalendarEvent> _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 = <CalendarEvent>[];
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<void> refreshFromServer({bool silent = false}) async {
if (!Get.isRegistered<MemoryService>()) return;
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);
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<CalendarEvent> get eventsForSelectedDate => events
.where((event) => DateUtils.isSameDay(event.date, selectedDate.value))
.toList();
/// 选中日期的代办事项(代办表)
List<CalendarEvent> get todosForSelectedDate => eventsForSelectedDate
.where((event) => event.category == CalendarEventCategory.todo)
.toList();
/// 选中日期的花销记录(花销表)
List<CalendarEvent> get expensesForSelectedDate => eventsForSelectedDate
.where((event) => event.category == CalendarEventCategory.expense)
.toList();
/// 折叠态横向条实际展示的条目:按 [categoryFilter] 筛选,null = 全部
///
/// ⚠️ 按值相等筛而不是 switch 逐个枚举:分类会随服务端增加(未知分类会落到
/// [CalendarEventCategory.other]),穷举式 switch 每加一类都得回来改一次,
/// 漏改就是编译错——而这一层根本不关心具体是哪一类。
List<CalendarEvent> 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);
}
/// 当前范围内的全部事件,按日期升序(同一天内保持原顺序)
List<CalendarEvent> get eventsForScope {
final list = events.where((e) => _inScope(e.date)).toList();
list.sort((a, b) => a.date.compareTo(b.date));
return list;
}
List<CalendarEvent> get todosForScope => eventsForScope
.where((e) => e.category == CalendarEventCategory.todo)
.toList();
List<CalendarEvent> 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<CalendarEvent> events})> get eventsGroupedByDay {
final map = <DateTime, List<CalendarEvent>>{};
for (final e in eventsForScope) {
final key = DateTime(e.date.year, e.date.month, e.date.day);
map.putIfAbsent(key, () => <CalendarEvent>[]).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<DateTime> 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];
}
}