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.
 
 
 
 
 
 

562 lines
19 KiB

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<CalendarEvent> events = <CalendarEvent>[].obs;
/// 监听助手提醒变化的 worker,onClose 要停掉
Worker? _reminderWorker;
// 折叠态横向条的代办/花销筛选:null=全部,非空=只看该类别。
// 点击"代办"/"花销"标签设置;点击日期(onDaySelected)重置为全部。
final Rx<CalendarEventCategory?> categoryFilter =
Rx<CalendarEventCategory?>(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<AssistantDirectiveService>()) {
_reminderWorker = ever<List<AssistantReminder>>(
AssistantDirectiveService.to.reminders,
(_) => _rebuildEvents(),
);
}
}
/// mock 演示数据 + EMAI 助手落下来的提醒
void _rebuildEvents() {
final list = <CalendarEvent>[...mockCalendarEvents()];
if (Get.isRegistered<AssistantDirectiveService>()) {
for (final r in AssistantDirectiveService.to.reminders) {
list.addAll(_expandReminder(r));
}
}
events.assignAll(list);
}
/// 把一条提醒摊成日历事件。
///
/// 重复提醒在协议里只有一条记录(`repeat=每天` / `周一`),日历要按天展示,
/// 所以在可见窗口内逐日摊开。窗口取「今天前 7 天 ~ 后 60 天」:往前留几天是
/// 为了让用户还能看到刚过去的提醒,往后 60 天覆盖月视图翻两页。
List<CalendarEvent> _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 = <CalendarEvent>[];
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<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 时代办+花销都显示
List<CalendarEvent> 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<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];
}
}