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.
664 lines
24 KiB
664 lines
24 KiB
import 'dart:async';
|
|
|
|
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_reminder_scheduler.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;
|
|
// 先把本地没同步上去的闹钟补传,再拉——顺序反了的话刚补传的这次拉不到,
|
|
// 要等下一次刷新才显示。
|
|
if (Get.isRegistered<AssistantDirectiveService>()) {
|
|
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} 个日历事件');
|
|
|
|
// 顺手重排一次系统通知。
|
|
//
|
|
// ⚠️ 不在这里排的话,冷启动那一轮**根本不会排**:reschedule 原先只挂在
|
|
// AppLifecycleManager 的「切回前台」上,而冷启动不走那条路径。表现就是
|
|
// 装好包、定个闹钟、一直没切过后台 —— 到点不响。
|
|
// 它同时会刷新 permissionDenied,首页那条「通知权限未开启」的提示条
|
|
// 也指望这一次调用才有值。
|
|
if (Get.isRegistered<MemoryReminderScheduler>()) {
|
|
unawaited(MemoryReminderScheduler.to.reschedule());
|
|
}
|
|
}
|
|
|
|
@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<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);
|
|
}
|
|
|
|
/// 当前范围内的全部事件,按日期升序(同一天内保持原顺序)
|
|
/// 当前范围内、且通过分类筛选的事项。
|
|
///
|
|
/// ⚠️ **必须读 categoryFilter**:拾忆页顶部的分类筛选条改的就是它。
|
|
/// 早先这里只按日期过滤,于是筛选条点了没有任何反应——值改了但没人读。
|
|
List<CalendarEvent> 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<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];
|
|
}
|
|
}
|
|
|