diff --git a/tdesign-component/coverage/lcov.info b/tdesign-component/coverage/lcov.info index 7c52f5e7e..018b00e48 100644 --- a/tdesign-component/coverage/lcov.info +++ b/tdesign-component/coverage/lcov.info @@ -1869,100 +1869,6 @@ DA:204,0 LF:104 LH:0 end_of_record -SF:lib/src/components/calendar/date_picker_model.dart -DA:33,0 -DA:48,0 -DA:49,0 -DA:50,0 -DA:51,0 -DA:55,0 -DA:58,0 -DA:59,0 -DA:60,0 -DA:64,0 -DA:67,0 -DA:70,0 -DA:73,0 -DA:80,0 -DA:82,0 -DA:83,0 -DA:84,0 -DA:85,0 -DA:88,0 -DA:90,0 -DA:91,0 -DA:92,0 -DA:93,0 -DA:94,0 -DA:97,0 -DA:99,0 -DA:100,0 -DA:101,0 -DA:102,0 -DA:103,0 -DA:104,0 -DA:108,0 -DA:109,0 -DA:110,0 -DA:112,0 -DA:113,0 -DA:114,0 -DA:115,0 -DA:116,0 -DA:117,0 -DA:118,0 -DA:120,0 -DA:121,0 -DA:122,0 -DA:126,0 -DA:127,0 -DA:128,0 -DA:129,0 -DA:130,0 -DA:131,0 -DA:137,0 -DA:141,0 -DA:144,0 -DA:145,0 -DA:146,0 -DA:148,0 -DA:149,0 -DA:151,0 -DA:152,0 -DA:153,0 -DA:155,0 -DA:156,0 -DA:157,0 -DA:161,0 -DA:162,0 -DA:164,0 -DA:165,0 -DA:167,0 -DA:169,0 -DA:170,0 -DA:175,0 -DA:176,0 -DA:178,0 -DA:179,0 -DA:180,0 -DA:182,0 -DA:183,0 -DA:184,0 -DA:186,0 -DA:187,0 -DA:188,0 -DA:190,0 -DA:191,0 -DA:192,0 -DA:194,0 -DA:195,0 -DA:196,0 -DA:198,0 -DA:199,0 -DA:200,0 -LF:90 -LH:0 -end_of_record SF:lib/src/components/picker/no_wave_behavior.dart DA:11,0 DA:14,0 @@ -2226,72 +2132,6 @@ DA:432,0 LF:171 LH:0 end_of_record -SF:lib/src/components/calendar/t_date_picker.dart -DA:22,0 -DA:35,0 -DA:36,0 -DA:42,0 -DA:44,0 -DA:45,0 -DA:46,0 -DA:49,0 -DA:51,0 -DA:53,0 -DA:54,0 -DA:55,0 -DA:56,0 -DA:57,0 -DA:59,0 -DA:60,0 -DA:61,0 -DA:62,0 -DA:64,0 -DA:65,0 -DA:66,0 -DA:67,0 -DA:68,0 -DA:70,0 -DA:75,0 -DA:76,0 -DA:78,0 -DA:80,0 -DA:81,0 -DA:82,0 -DA:85,0 -DA:87,0 -DA:88,0 -DA:89,0 -DA:93,0 -DA:95,0 -DA:96,0 -DA:97,0 -DA:98,0 -DA:109,0 -DA:110,0 -DA:111,0 -DA:113,0 -DA:114,0 -DA:116,0 -DA:117,0 -DA:118,0 -DA:119,0 -DA:121,0 -DA:123,0 -DA:125,0 -DA:126,0 -DA:128,0 -DA:130,0 -DA:131,0 -DA:132,0 -DA:133,0 -DA:134,0 -DA:136,0 -DA:138,0 -DA:140,0 -DA:143,0 -LF:62 -LH:0 -end_of_record SF:lib/src/components/calendar/t_calendar_body.dart DA:10,0 DA:31,0 diff --git a/tdesign-component/demo_tool/all_build.sh b/tdesign-component/demo_tool/all_build.sh index 1a8f8977d..f054eeb88 100644 --- a/tdesign-component/demo_tool/all_build.sh +++ b/tdesign-component/demo_tool/all_build.sh @@ -40,7 +40,7 @@ dart run tdesign_flutter_tools:main generate --folder "$PARENT_DIR/lib/src/compo # 输入 # calendar -dart run tdesign_flutter_tools:main generate --folder "$PARENT_DIR/lib/src/components/calendar" --name TCalendar,TCalendarPopup,TCalendarStyle,TCalendarDataSource,TLunarInfo,TCalendarDateType --folder-name calendar --output "$PARENT_DIR/example/assets/api/" --only-api +dart run tdesign_flutter_tools:main generate --folder "$PARENT_DIR/lib/src/components/calendar" --name TCalendar,TCalendarInherited,TCalendarStyle,TCalendarDataSource,TCalendarCellModel --folder-name calendar --output "$PARENT_DIR/example/assets/api/" --only-api # cascader dart run tdesign_flutter_tools:main generate --folder "$PARENT_DIR/lib/src/components/cascader" --name TMultiCascader --folder-name cascader --output "$PARENT_DIR/example/assets/api/" --only-api diff --git a/tdesign-component/example/assets/api/calendar_api.md b/tdesign-component/example/assets/api/calendar_api.md index ebe8e8487..b71884071 100644 --- a/tdesign-component/example/assets/api/calendar_api.md +++ b/tdesign-component/example/assets/api/calendar_api.md @@ -2,61 +2,115 @@ ### TCalendar #### 简介 日历组件 + +#### 静态方法 + +##### TCalendar.showPopup + +弹出日历选择器,返回选中的日期列表。 +取消或关闭弹窗时返回 `null`;点击确认时返回选中的 `DateTime` 列表。 +弹窗内点选过程无 `onChange`;实时联动请用 `popupOverlayBuilder` 的 `dates`, +或自行用 `TCalendarInherited` 监听 `TCalendarInherited.selectedListenable`。 +```dart +final result = await TCalendar.showPopup( + context, + titleWidget: Text('请选择日期'), + type: CalendarType.single, +); +if (result != null) { + print('选中了: $result'); +} +``` +若需完全自定义布局,请直接使用 `TCalendar` + `TPopup.show` +/ `TPopupOptions.bottom` 自行组装。 + +返回类型:`Future?>` + +| 参数 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| context | BuildContext | - | - | +| titleWidget | Widget? | - | 标题组件,可传入 Text 或自定义 Widget | +| type | CalendarType | CalendarType.single | 日历的选择模式,决定点击日期后的选中行为: - `CalendarType.single`:单选,点击新日期取消旧选中 - `CalendarType.multiple`:多选,点击切换选中/取消 - `CalendarType.range`:区间选择,依次选起止日期 | +| initialValue | List? | - | 初始选中日期列表,不传则默认今天。 **非受控语义**:仅用于首次挂载;用户点选后以 `onChange` 为准,由调用方自行 `setState` 保存。若父组件在运行期修改本参数,会同步选中态并刷新格子(与 range 行为一致)。 列表长度与 `type` 对应: - `CalendarType.single`:1 个元素(选中日期) - `CalendarType.multiple`:N 个元素(所有选中日期) - `CalendarType.range`:2 个元素(起始、结束日期) | +| minDate | DateTime? | - | 最小可选的日期,不传则默认 1970-01-01 | +| maxDate | DateTime? | - | 最大可选的日期,不传则默认 2100-12-31 | +| anchorDate | DateTime? | - | 锚点日期,打开时滚动到该日期所在月份。 | +| anchorRevision | int | 0 | 锚点滚动触发序号,默认 `0`。 与 `anchorDate` 配合:序号递增可重复滚到同一月份;仅改月份时也可只更新 `anchorDate`。 | +| popupHeight | double? | - | - | +| firstDayOfWeek | int | 0 | 第一天从星期几开始,0 = 周日,1 = 周一,…,6 = 周六。默认 0(周日)。 | +| cellHeight | double? | - | 日期单元格高度,默认 60。如需更大行高可传入自定义值(如 80) | +| style | TCalendarStyle? | - | 自定义样式 | +| popupOverlayBuilder | Widget Function(BuildContext context, List selectedDates)? | - | - | +| popupOverlayExpanded | ValueListenable? | - | - | +| confirmBtnBuilder | Widget Function(VoidCallback onConfirm)? | - | - | +| onConfirm | void Function(List)? | - | - | +| onClose | VoidCallback? | - | - | +| onCellClick | void Function(DateTime value, DateSelectType selectType, TCalendarCellModel cell)? | - | 点击日期时触发 | +| cellBuilder | TCalendarCellBuilder? | - | 整格自定义;设置后不再使用默认主区/副标题布局。 | +| subtitleBuilder | TCalendarSubtitleBuilder? | - | 副标题完全自定义;未设置时可使用 `dataSource.getSubtitle`。 | +| dataSource | TCalendarDataSource? | - | 可选数据源,提供副标题字符串(无 `subtitleBuilder` 时生效)。 | +| onMonthChange | ValueChanged? | - | 月份变化时触发 | +| monthTitleBuilder | Widget Function(BuildContext context, DateTime monthDate)? | - | 月标题构建器 | + #### 默认构造方法 | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| anchorDate | DateTime? | - | 锚点日期 | -| animateTo | bool? | false | 动画滚动到指定位置 | -| cellHeight | double? | 60 | 日期高度 | -| cellWidget | Widget? Function(BuildContext context, TDate tdate, DateSelectType selectType)? | - | 自定义日期单元格组件 | -| dataSource | TCalendarDataSource? | - | 外部数据源,用于提供农历转换等功能 | -| dateType | TCalendarDateType | TCalendarDateType.solar | 日历类型:阳历或农历 | -| displayFormat | String? | 'year month' | 年月显示格式,`year`表示年,`month`表示月,如`year month`表示年在前、月在后、中间隔一个空格 | -| firstDayOfWeek | int? | 0 | 第一天从星期几开始,默认 0 = 周日 | -| format | CalendarFormat? | - | 用于格式化日期的函数,可定义日期前后的显示内容和日期样式 | -| height | double? | - | 高度 | -| isTimeUnit | bool? | true | 是否显示时间单位 | +| anchorDate | DateTime? | - | 锚点日期,打开时滚动到该日期所在月份。 | +| anchorRevision | int | 0 | 锚点滚动触发序号,默认 `0`。 与 `anchorDate` 配合:序号递增可重复滚到同一月份;仅改月份时也可只更新 `anchorDate`。 | +| animateTo | bool | false | 滚动到选中日期/锚点日期所在月份时是否使用动画,默认 false | +| cellBuilder | TCalendarCellBuilder? | - | 整格自定义;设置后不再使用默认主区/副标题布局。 | +| cellHeight | double? | - | 日期单元格高度,默认 60。如需更大行高可传入自定义值(如 80) | +| dataSource | TCalendarDataSource? | - | 可选数据源,提供副标题字符串(无 `subtitleBuilder` 时生效)。 | +| firstDayOfWeek | int | 0 | 第一天从星期几开始,0 = 周日,1 = 周一,…,6 = 周六。默认 0(周日)。 | +| height | double? | - | 高度,不传时内嵌模式自动按 5 行日期计算 | +| initialValue | List? | - | 初始选中日期列表,不传则默认今天。 **非受控语义**:仅用于首次挂载;用户点选后以 `onChange` 为准,由调用方自行 `setState` 保存。若父组件在运行期修改本参数,会同步选中态并刷新格子(与 range 行为一致)。 列表长度与 `type` 对应: - `CalendarType.single`:1 个元素(选中日期) - `CalendarType.multiple`:N 个元素(所有选中日期) - `CalendarType.range`:2 个元素(起始、结束日期) | | key | Key? | - | 组件标识,用于区分或保留组件状态。 | -| maxDate | int? | - | 最大可选的日期(fromMillisecondsSinceEpoch),不传则默认半年后 | -| minDate | int? | - | 最小可选的日期(fromMillisecondsSinceEpoch),不传则默认今天 | +| maxDate | DateTime? | - | 最大可选的日期,不传则默认 2100-12-31 | +| minDate | DateTime? | - | 最小可选的日期,不传则默认 1970-01-01 | | monthTitleBuilder | Widget Function(BuildContext context, DateTime monthDate)? | - | 月标题构建器 | -| monthTitleHeight | double? | 22 | 月标题高度 | -| onCellClick | void Function(int value, DateSelectType type, TDate tdate)? | - | 点击日期时触发 | -| onCellLongPress | void Function(int value, DateSelectType type, TDate tdate)? | - | 长安日期时触发 | -| onChange | void Function(List value)? | - | 选中值变化时触发 | -| onHeaderClick | void Function(int index, String week)? | - | 点击周时触发 | +| monthTitleHeight | double | 22 | 每月标题行高度(如 '2025年6月' 所在行),默认 22 | +| onCellClick | void Function(DateTime value, DateSelectType selectType, TCalendarCellModel cell)? | - | 点击日期时触发 | +| onChange | void Function(List value)? | - | 选中值变化时触发 | | onMonthChange | ValueChanged? | - | 月份变化时触发 | -| pickerHeight | double? | 178 | 时间选择器List的视窗高度 | -| pickerItemCount | int? | 3 | 选择器List视窗中item个数,pickerHeight / pickerItemCount即item高度 | -| showLunarInfo | bool | false | 阳历模式下是否显示农历信息作为副标题 | +| safeAreaInset | bool | true | 是否适配底部安全区域(如 iPhone Home Indicator),默认 true | | style | TCalendarStyle? | - | 自定义样式 | -| timePickerModel | List? | - | 自定义时间选择器 | -| title | String? | - | 标题 | -| titleWidget | Widget? | - | 标题组件 | -| type | CalendarType? | CalendarType.single | 日历的选择类型,single = 单选;multiple = 多选;range = 区间选择 | -| useSafeArea | bool? | true | 是否使用安全区域,默认true | -| useTimePicker | bool? | false | 是否显示时间选择器 | -| value | List? | - | 当前选择的日期(fromMillisecondsSinceEpoch),不传则默认今天,当 type = single 时数组长度为1 | -| width | double? | - | 宽度 | +| subtitleBuilder | TCalendarSubtitleBuilder? | - | 副标题完全自定义;未设置时可使用 `dataSource.getSubtitle`。 | +| titleWidget | Widget? | - | 标题组件,可传入 Text 或自定义 Widget | +| type | CalendarType | CalendarType.single | 日历的选择模式,决定点击日期后的选中行为: - `CalendarType.single`:单选,点击新日期取消旧选中 - `CalendarType.multiple`:多选,点击切换选中/取消 - `CalendarType.range`:区间选择,依次选起止日期 | -### TCalendarPopup +### TCalendarInherited #### 简介 -单元格组件popup模式 +日历弹窗状态的 InheritedWidget 容器。 +由上层(如 `TSlidePopupRoute` 的 builder)包裹在 `TCalendar` 外侧, +将选中态、确认/关闭回调等注入子树。 + +#### 静态方法 + +##### TCalendarInherited.of + +返回类型:`TCalendarInherited?` + +| 参数 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| context | BuildContext | - | - | + #### 默认构造方法 | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| context | BuildContext | - | 上下文 | -| autoClose | bool? | true | 自动关闭;在点击关闭按钮、确认按钮、遮罩层时自动关闭 | -| builder | CalendarBuilder? | - | 控件构建器,优先级高于`child` | -| child | TCalendar? | - | 日历控件 | -| confirmBtn | Widget? | - | 自定义确认按钮 | -| onClose | VoidCallback? | - | 关闭时触发 | -| onConfirm | void Function(List value)? | - | 点击确认按钮时触发 | -| top | double? | - | 距离顶部的距离 | -| visible | bool? | - | 默认是否显示日历 | +| child | Widget | - | - | +| confirmBtnBuilder | Widget Function(VoidCallback onConfirm)? | - | 自定义确认按钮;`onConfirm` 与默认确认按钮一致(回传选中值并关闭弹窗)。 | +| key | Key? | - | 组件标识,用于区分或保留组件状态。 | +| onClose | VoidCallback? | - | - | +| onConfirm | VoidCallback? | - | - | +| popupConfirmBtn | bool? | - | 是否由 `TCalendar` 渲染底部确认按钮。 为 `null`(默认)时跟随 `popupControls`;显式设置时覆盖。 | +| popupControls | bool | true | 是否由 `TCalendar` 自行渲染关闭按钮和标题行。 为 `true`(默认)时 `TCalendar` 渲染关闭按钮与标题行; 为 `false` 时由外层弹窗容器承载。 | +| popupOverlayBuilder | Widget Function(BuildContext context, List selectedDates)? | - | 弹窗模式下日历内容区底部浮层构建器(非 `TPopup` 面板底部)。 由 `TCalendar.showPopup` 或手动 `TCalendarInherited` 注入; `selectedDates` 随点选实时更新。 | +| popupOverlayExpanded | ValueListenable? | - | 浮层是否展开(响应式),需配合 `popupOverlayBuilder`。 | +| selected | ValueNotifier> | - | 选中态的可写引用(仅供 `TCalendar` 内部更新使用)。 对外消费方请使用 `selectedListenable` 这一只读视图。 | +| usePopup | bool? | true | - | ### TCalendarStyle @@ -65,9 +119,9 @@ #### 工厂构造方法 -##### TCalendarStyle.cellStyle +##### TCalendarStyle.forSelectType -日期样式 +按选中态生成单元格样式 | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | @@ -88,15 +142,15 @@ | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | cellDecoration | BoxDecoration? | - | 日期decoration | -| cellPrefixStyle | TextStyle? | - | 日期前面的字符串的样式 | -| cellStyle | TextStyle? | - | 日期样式 | -| cellSuffixStyle | TextStyle? | - | 日期后面的字符串的样式 | | centreColor | Color? | - | 日期范围内背景样式 | +| dayStyle | TextStyle? | - | 日期主区(默认阳历日数字)样式 | | decoration | BoxDecoration? | - | - | | monthTitleStyle | TextStyle? | - | body区域 年月文字样式 | +| subtitleStyle | TextStyle? | - | 副标题样式(仅 `TCalendarDataSource.getSubtitle` 字符串路径使用) | | titleCloseColor | Color? | - | header区域 关闭图标的颜色 | -| titleMaxLine | int? | - | header区域 `TCalendar.title`的行数 | -| titleStyle | TextStyle? | - | header区域 `TCalendar.title`的样式 | +| titleMaxLine | int? | - | header区域 `TCalendar.titleWidget`的行数 | +| titleStyle | TextStyle? | - | header区域 `TCalendar.titleWidget`的样式 | +| todayDayStyle | TextStyle? | - | 今天日期主区样式 | | weekdayStyle | TextStyle? | - | header区域 周 文字样式 | #### 公开属性 @@ -104,82 +158,50 @@ | 属性 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | bodyPadding | double? | - | 月与月之间的垂直间距 | -| todayStyle | TextStyle? | - | 当天日期样式 | | verticalGap | double? | - | 日期垂直间距,水平间距为`verticalGap` / 2 | ### TCalendarDataSource #### 简介 -日历数据源接口 - -开发者需要实现此接口来提供农历转换能力。 -组件内部不包含农历算法和数据,完全依赖外部实现。 +日历可选数据源:仅提供副标题文案(无 `subtitleBuilder` 时使用)。 +农历、节气、节日等均由接入方在 `TCalendar.subtitleBuilder` 或 +`getSubtitle` 中自行处理;组件主区默认只渲染阳历日数字。 #### 方法 | 名称 | 返回类型 | 参数 | 说明 | | --- | --- | --- | --- | -| getLunarInfo | TLunarInfo? | required DateTime solarDate | 获取指定阳历日期的农历信息 返回 null 表示不显示农历信息 | -| formatDate | String | required DateTime date, required TCalendarDateType type, TLunarInfo? lunarInfo | 格式化日期文本 返回格式化后的日期字符串 | -| getSolarTerm | String? | required DateTime date | 获取节气信息(可选实现) 返回节气名称,如"春分"、"秋分"等,无节气则返回 null | -| getFestival | String? | required DateTime date, TLunarInfo? lunarInfo | 获取节日信息(可选实现) 返回节日名称,如"春节"、"中秋节"等,无节日则返回 null | -| getHolidayInfo | Map? | required DateTime date | 获取假期信息(可选实现) 返回假期类型和名称: - 'holiday': 法定节假日/公共假期(如"国庆节") - 'workday': 调休工作日(如"补班") - null: 正常日期 示例返回值: - {'type': 'holiday', 'name': '国庆节'} - {'type': 'workday', 'name': '补班'} - null | -| formatYear | String | required int year, required TCalendarDateType type | 格式化年份文本 返回格式化后的年份字符串 阳历示例:2025 -> "2025年" 阴历示例:2025 -> "二〇二五年" | -| formatMonth | String | required int month, required TCalendarDateType type, bool isLeapMonth | 格式化月份文本 返回格式化后的月份字符串 阳历示例:3 -> "3月" 阴历示例:3 -> "三月",闰3月 -> "闰三月" | -| formatDay | String | required int day, required TCalendarDateType type | 格式化日期文本 返回格式化后的日期字符串 阳历示例:7 -> "7日" 阴历示例:7 -> "初七" | +| getSubtitle | String? | required DateTime date | 副标题文案;返回 null 或空字符串时不显示副标题行。 | -### TLunarInfo +### TCalendarCellModel #### 简介 -农历日期信息模型 +单个日期格数据(只读,选中态通过 `typeNotifier` 更新) #### 默认构造方法 | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| day | int | - | 农历日期(数字,1-30) | -| dayText | String | - | 日期文本(如:初七) | -| isLeapMonth | bool | false | 是否是闰月 | -| month | int | - | 农历月份(数字,1-12) | -| monthText | String | - | 月份文本(如:三月、闰三月) | -| year | int | - | 农历年份(数字) | -| yearText | String | - | 年份文本(如:二〇二五) | - - -### TCalendarDateType -#### 简介 -日历类型枚举 -#### 枚举值 - - -| 名称 | 说明 | -| --- | --- | -| solar | 阳历(公历) | -| lunar | 阴历(农历) | +| date | DateTime | - | - | +| isLastDayOfMonth | bool | - | - | +| typeNotifier | DateSelectTypeNotifier | - | - | ### CalendarType +#### 简介 +日历选择模式 #### 枚举值 | 名称 | 说明 | | --- | --- | -| single | - | -| multiple | - | -| range | - | - - -### CalendarTrigger -#### 枚举值 - - -| 名称 | 说明 | -| --- | --- | -| closeBtn | - | -| confirmBtn | - | -| overlay | - | +| single | 单选:点击新日期时自动取消旧日期的选中状态 | +| multiple | 多选:点击日期切换选中/取消,可同时选中多个日期 | +| range | 区间选择:第一次点击选起点,第二次点击选终点,中间自动填充 | ### DateSelectType +#### 简介 +日期在日历格中的选中/展示状态 #### 枚举值 @@ -193,17 +215,21 @@ | empty | - | -### CalendarFormat +### TCalendarSubtitleBuilder +#### 简介 +副标题完全自定义 #### 类型定义 ```dart -typedef CalendarFormat = TDate? Function(TDate? day); +typedef TCalendarSubtitleBuilder = Widget? Function(BuildContext context, TCalendarSubtitleContext subtitleContext); ``` -### CalendarBuilder +### TCalendarCellBuilder +#### 简介 +整格自定义(主区 + 副标题均由接入方绘制) #### 类型定义 ```dart -typedef CalendarBuilder = Widget Function(BuildContext context); +typedef TCalendarCellBuilder = Widget? Function(BuildContext context, TCalendarCellModel cell); ``` diff --git a/tdesign-component/example/assets/code/calendar._buildBlock.txt b/tdesign-component/example/assets/code/calendar._buildBlock.txt deleted file mode 100644 index fa5987012..000000000 --- a/tdesign-component/example/assets/code/calendar._buildBlock.txt +++ /dev/null @@ -1,48 +0,0 @@ - -Widget _buildBlock(BuildContext context) { - final size = MediaQuery.of(context).size; - final selected = ValueNotifier>( - [DateTime.now().millisecondsSinceEpoch + 30 * 24 * 60 * 60 * 1000], - ); - return Column( - // spacing: TTheme.of(context).spacer16, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - // spacing: TTheme.of(context).spacer16, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TButton( - text: '加一个月', - theme: TButtonTheme.primary, - onTap: () { - selected.value = [selected.value[0] + 30 * 24 * 60 * 60 * 1000]; - }, - ), - const SizedBox(width: 16), - TButton( - text: '减一个月', - theme: TButtonTheme.primary, - onTap: () { - selected.value = [selected.value[0] - 30 * 24 * 60 * 60 * 1000]; - }, - ), - ], - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: selected, - builder: (context, value, child) { - return TCalendar( - title: '请选择日期', - value: value, - height: size.height * 0.6 + 176, - animateTo: true, - // 不使用popup时,useSafeArea无效 - useSafeArea: true, - ); - }, - ), - ], - ); -} \ No newline at end of file diff --git a/tdesign-component/example/assets/code/calendar._buildCustomCell.txt b/tdesign-component/example/assets/code/calendar._buildCustomCell.txt deleted file mode 100644 index dc30963b4..000000000 --- a/tdesign-component/example/assets/code/calendar._buildCustomCell.txt +++ /dev/null @@ -1,120 +0,0 @@ - -Widget _buildCustomCell(BuildContext context) { - final size = MediaQuery.of(context).size; - final selected = ValueNotifier>( - [DateTime.now().millisecondsSinceEpoch + 30 * 24 * 60 * 60 * 1000]); - return ValueListenableBuilder( - valueListenable: selected, - builder: (context, value, child) { - final date = DateTime.fromMillisecondsSinceEpoch(value[0]); - return TCellGroup( - cells: [ - TCell( - title: '自定义日期单元格', - arrow: true, - note: '${date.year}-${date.month}-${date.day}', - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - onConfirm: (value) { - print('onConfirm:$value'); - selected.value = value; - }, - onClose: () { - print('onClose'); - }, - child: TCalendar( - title: '请选择日期', - value: value, - cellHeight: 80, - height: size.height * 0.6 + 176, - onCellClick: (value, type, tdate) { - print('onCellClick: $value'); - }, - onCellLongPress: (value, type, tdate) { - print('onCellLongPress: $value'); - }, - onHeaderClick: (index, week) { - print('onHeaderClick: $week'); - }, - onChange: (value) { - print('onChange: $value'); - }, - cellWidget: (context, tdate, selectType) { - final today = DateTime.now(); - //当前日期的自定义实现 - if (tdate.date.millisecondsSinceEpoch == - DateTime(today.year, today.month, today.day) - .millisecondsSinceEpoch && - selectType != DateSelectType.selected) { - return Container( - decoration: BoxDecoration( - color: TTheme.of(context).brandColor4, - borderRadius: BorderRadius.all(Radius.circular(6)), - ), - constraints: const BoxConstraints( - minWidth: 0, // 最小宽度为0 - maxWidth: double.infinity, // 最大宽度无限 - minHeight: 0, // 最小高度为0 - maxHeight: double.infinity), - child: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('今天', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white)), - ], - ), - ); - } - if (selectType == DateSelectType.selected) { - return Container( - decoration: BoxDecoration( - color: TTheme.of(context).successColor8, - borderRadius: BorderRadius.all(Radius.circular(6)), - ), - constraints: const BoxConstraints( - minWidth: 0, // 最小宽度为0 - maxWidth: double.infinity, // 最大宽度无限 - minHeight: 0, // 最小高度为0 - maxHeight: double.infinity), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('${tdate.date.day}', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white)), - const Text('文案文案', - style: TextStyle( - fontSize: 6, color: Colors.white)), - const Text('自定义', - style: TextStyle( - fontSize: 12, color: Colors.white)), - ], - ), - ); - } - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('${tdate.date.day}', - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), - const Text('文案文案', style: TextStyle(fontSize: 8)), - const Text('自定义', style: TextStyle(fontSize: 8)), - ], - ); - }), - ); - }, - ), - ], - ); - }, - ); -} \ No newline at end of file diff --git a/tdesign-component/example/assets/code/calendar._buildLunar.txt b/tdesign-component/example/assets/code/calendar._buildLunar.txt index 10c2065af..16a41912b 100644 --- a/tdesign-component/example/assets/code/calendar._buildLunar.txt +++ b/tdesign-component/example/assets/code/calendar._buildLunar.txt @@ -1,304 +1,4 @@ Widget _buildLunar(BuildContext context) { - final size = MediaQuery.of(context).size; - final dataSource = LunarDataSourceExample(); - - // 当前月份状态 - final currentMonth = ValueNotifier( - DateTime(DateTime.now().year, DateTime.now().month, 1), - ); - - // 农历开关状态 - final showLunarInfo = ValueNotifier(true); - - // 选中日期 - final selectedDate = ValueNotifier>([ - DateTime.now().millisecondsSinceEpoch, - ]); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 控制栏 - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.grey.shade50, - border: Border( - bottom: BorderSide(color: Colors.grey.shade200), - ), - ), - child: ValueListenableBuilder( - valueListenable: currentMonth, - builder: (context, month, child) { - // 获取当前月份的农历信息 - final lunarInfo = dataSource.getLunarInfo(month); - final lunarMonth = lunarInfo != null - ? '${lunarInfo.yearText}年 ${lunarInfo.monthText}' - : ''; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 农历年月显示 - if (lunarMonth.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - lunarMonth, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade700, - fontWeight: FontWeight.w500, - ), - ), - ), - // 按钮行 - Row( - children: [ - // 上一月按钮 - TButton( - text: '上一月', - size: TButtonSize.small, - theme: TButtonTheme.primary, - onTap: () { - currentMonth.value = DateTime( - month.year, - month.month - 1, - 1, - ); - selectedDate.value = [currentMonth.value.millisecondsSinceEpoch]; - }, - ), - const SizedBox(width: 8), - // 年份选择 - Expanded( - child: TButton( - text: '${month.year}年', - size: TButtonSize.small, - theme: TButtonTheme.defaultTheme, - onTap: () async { - final year = await showModalBottomSheet( - context: context, - builder: (context) { - return SizedBox( - height: 300, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - '选择年份', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: ListView.builder( - itemCount: 50, - itemBuilder: (context, index) { - final year = DateTime.now().year - 10 + index; - final isSelected = year == month.year; - return ListTile( - title: Text( - '$year年', - style: TextStyle( - color: isSelected ? Colors.blue : null, - fontWeight: isSelected ? FontWeight.bold : null, - ), - ), - onTap: () => Navigator.pop(context, year), - ); - }, - ), - ), - ], - ), - ); - }, - ); - if (year != null) { - currentMonth.value = DateTime(year, month.month, 1); - selectedDate.value = [currentMonth.value.millisecondsSinceEpoch]; - } - }, - ), - ), - const SizedBox(width: 8), - // 月份选择 - Expanded( - child: TButton( - text: '${month.month}月', - size: TButtonSize.small, - theme: TButtonTheme.defaultTheme, - onTap: () async { - final selectedMonth = await showModalBottomSheet( - context: context, - builder: (context) { - return SizedBox( - height: 400, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - '选择月份', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - ), - itemCount: 12, - itemBuilder: (context, index) { - final m = index + 1; - final isSelected = m == month.month; - return InkWell( - onTap: () => Navigator.pop(context, m), - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: isSelected ? Colors.blue : Colors.grey.shade200, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '$m月', - style: TextStyle( - color: isSelected ? Colors.white : Colors.black, - fontWeight: isSelected ? FontWeight.bold : null, - ), - ), - ), - ); - }, - ), - ), - ], - ), - ); - }, - ); - if (selectedMonth != null) { - currentMonth.value = DateTime(month.year, selectedMonth, 1); - selectedDate.value = [currentMonth.value.millisecondsSinceEpoch]; - } - }, - ), - ), - const SizedBox(width: 8), - // 下一月按钮 - TButton( - text: '下一月', - size: TButtonSize.small, - theme: TButtonTheme.primary, - onTap: () { - currentMonth.value = DateTime( - month.year, - month.month + 1, - 1, - ); - selectedDate.value = [currentMonth.value.millisecondsSinceEpoch]; - }, - ), - const SizedBox(width: 16), - // 农历开关 - ValueListenableBuilder( - valueListenable: showLunarInfo, - builder: (context, show, child) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '农历', - style: TextStyle(fontSize: 12, color: Colors.grey.shade700), - ), - Switch( - value: show, - onChanged: (value) { - showLunarInfo.value = value; - }, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ], - ); - }, - ), - ], - ), - ], - ); - }, - ), - ), - const SizedBox(height: 16), - // 日历主体 - ValueListenableBuilder( - valueListenable: showLunarInfo, - builder: (context, show, child) { - return ValueListenableBuilder( - valueListenable: selectedDate, - builder: (context, value, child) { - return TCalendar( - title: '', - showLunarInfo: show, - dataSource: dataSource, - value: value, - height: size.height * 0.6, - onChange: (newValue) { - selectedDate.value = newValue; - - // 显示完整农历信息 - final date = DateTime.fromMillisecondsSinceEpoch(newValue[0]); - final lunarInfo = dataSource.getLunarInfo(date); - final solarTerm = dataSource.getSolarTerm(date); - final festival = dataSource.getFestival(date, lunarInfo); - final holidayInfo = dataSource.getHolidayInfo(date); - - final buffer = StringBuffer(); - buffer.write('阳历:${date.year}年${date.month}月${date.day}日'); - - if (lunarInfo != null) { - buffer.write('\n农历:${lunarInfo.monthText}${lunarInfo.dayText}'); - } - - if (solarTerm != null && solarTerm.isNotEmpty) { - buffer.write('\n节气:$solarTerm'); - } - - if (festival != null && festival.isNotEmpty) { - buffer.write('\n节日:$festival'); - } - - if (holidayInfo != null) { - final type = holidayInfo['type'] == 'holiday' ? '假期' : '调休'; - buffer.write('\n$type:${holidayInfo['name']}'); - } - - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(buffer.toString()), - duration: const Duration(seconds: 3), - behavior: SnackBarBehavior.floating, - ), - ); - }, - ); - }, - ); - }, - ), - ], - ); + return const _LunarCalendarDemo(); } \ No newline at end of file diff --git a/tdesign-component/example/assets/code/calendar._buildSimple.txt b/tdesign-component/example/assets/code/calendar._buildSimple.txt index 8c260fc0f..58d96d7de 100644 --- a/tdesign-component/example/assets/code/calendar._buildSimple.txt +++ b/tdesign-component/example/assets/code/calendar._buildSimple.txt @@ -1,205 +1,4 @@ Widget _buildSimple(BuildContext context) { - final size = MediaQuery.of(context).size; - final selected = ValueNotifier>( - [DateTime.now().millisecondsSinceEpoch + 30 * 24 * 60 * 60 * 1000]); - return ValueListenableBuilder( - valueListenable: selected, - builder: (context, value, child) { - final date = DateTime.fromMillisecondsSinceEpoch(value[0]); - return TCellGroup( - cells: [ - TCell( - title: '单个选择日历', - arrow: true, - note: '${date.year}-${date.month}-${date.day}', - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - onConfirm: (value) { - print('onConfirm:$value'); - selected.value = value; - }, - onClose: () { - print('onClose'); - }, - child: TCalendar( - title: '请选择日期', - value: value, - height: size.height * 0.6 + 176, - onCellClick: (value, type, tdate) { - print('onCellClick: $value'); - }, - onCellLongPress: (value, type, tdate) { - print('onCellLongPress: $value'); - }, - onHeaderClick: (index, week) { - print('onHeaderClick: $week'); - }, - onChange: (value) { - print('onChange: $value'); - }, - ), - ); - }, - ), - TCell( - title: '多个选择日历', - arrow: true, - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - child: TCalendar( - title: '请选择日期', - type: CalendarType.multiple, - value: [DateTime.now().millisecondsSinceEpoch], - height: size.height * 0.6 + 176, - ), - ); - }, - ), - TCell( - title: '区间选择日历', - arrow: true, - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - child: TCalendar( - title: '请选择日期区间', - type: CalendarType.range, - value: [ - DateTime.now().millisecondsSinceEpoch, - DateTime.now() - .add(const Duration(days: 6)) - .millisecondsSinceEpoch, - ], - height: size.height * 0.6 + 176, - ), - ); - }, - ), - TCell( - title: '单个选择日历和时间', - arrow: true, - note: - '${date.year}-${date.month}-${date.day} ${date.hour}:${date.minute}', - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - onConfirm: (value) { - print('onConfirm:$value'); - selected.value = value; - }, - onClose: () { - print('onClose'); - }, - child: TCalendar( - title: '请选择日期和时间', - value: value, - height: size.height * 0.92, - useTimePicker: true, - // pickerHeight: 100, - // pickerItemCount: 2, - onCellClick: (value, type, tdate) { - print('onCellClick: $value'); - }, - onCellLongPress: (value, type, tdate) { - print('onCellLongPress: $value'); - }, - onHeaderClick: (index, week) { - print('onHeaderClick: $week'); - }, - onChange: (value) { - print('onChange: $value'); - }, - ), - ); - }, - ), - TCell( - title: '区间选择日历和时间', - arrow: true, - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - onConfirm: (value) { - print('onConfirm: $value'); - }, - onClose: () { - print('onClose'); - }, - child: TCalendar( - title: '请选择日期和时间区间', - height: size.height * 0.92, - type: CalendarType.range, - value: [ - DateTime.now().millisecondsSinceEpoch, - DateTime.now() - .add(const Duration(days: 3)) - .millisecondsSinceEpoch, - ], - useTimePicker: true, - onCellClick: (value, type, tdate) { - print('onCellClick: $value'); - }, - onCellLongPress: (value, type, tdate) { - print('onCellLongPress: $value'); - }, - onHeaderClick: (index, week) { - print('onHeaderClick: $week'); - }, - onChange: (value) { - print('onChange: $value'); - }, - ), - ); - }, - ), - TCell( - title: '添加锚点', - arrow: true, - note: '${date.year}-${date.month}-${date.day}', - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - onConfirm: (value) { - print('onConfirm:$value'); - selected.value = value; - }, - onClose: () { - print('onClose'); - }, - child: TCalendar( - title: '请选择日期', - minDate: DateTime(2022, 1, 1).millisecondsSinceEpoch, - maxDate: DateTime(2028, 2, 15).millisecondsSinceEpoch, - anchorDate: DateTime(2026, 5), - value: value, - height: size.height * 0.6 + 176, - onCellClick: (value, type, tdate) { - print('onCellClick: $value'); - }, - onCellLongPress: (value, type, tdate) { - print('onCellLongPress: $value'); - }, - onHeaderClick: (index, week) { - print('onHeaderClick: $week'); - }, - onChange: (value) { - print('onChange: $value'); - }, - ), - ); - }, - ), - ], - ); - }, - ); + return const _SimpleDemo(); } \ No newline at end of file diff --git a/tdesign-component/example/assets/code/calendar._buildStyle.txt b/tdesign-component/example/assets/code/calendar._buildStyle.txt index 6bbfac6c0..054cbc8ec 100644 --- a/tdesign-component/example/assets/code/calendar._buildStyle.txt +++ b/tdesign-component/example/assets/code/calendar._buildStyle.txt @@ -1,6 +1,5 @@ Widget _buildStyle(BuildContext context) { - final size = MediaQuery.of(context).size; const map = { 1: '初一', 2: '初二', @@ -8,93 +7,172 @@ Widget _buildStyle(BuildContext context) { 14: '情人节', 15: '元宵节', }; - return TCellGroup( - cells: [ - TCell( - title: '自定义文案', - arrow: true, - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - child: TCalendar( - title: '请选择日期', - height: size.height * 0.6 + 176, - minDate: DateTime(2022, 1, 1).millisecondsSinceEpoch, - maxDate: DateTime(2022, 2, 15).millisecondsSinceEpoch, - format: (day) { - day?.suffix = '¥60'; - if (day?.date.month == 2) { - if (map.keys.contains(day?.date.day)) { - day?.suffix = '¥100'; - day?.prefix = map[day.date.day]; - day?.style = TextStyle( - fontSize: TTheme.of(context).fontTitleMedium?.size, - height: TTheme.of(context).fontTitleMedium?.height, - fontWeight: - TTheme.of(context).fontTitleMedium?.fontWeight, - color: TTheme.of(context).errorColor6, + + final customTextSelected = + ValueNotifier>([DateTime(2022, 1, 15)]); + final customBtnSelected = + ValueNotifier>([DateTime.now()]); + final customCellSelected = ValueNotifier>( + [DateTime.now().add(const Duration(days: 30))]); + + return ValueListenableBuilder( + valueListenable: customTextSelected, + builder: (context, textSelected, _) { + return ValueListenableBuilder( + valueListenable: customBtnSelected, + builder: (context, btnSelected, _) { + return ValueListenableBuilder( + valueListenable: customCellSelected, + builder: (context, cellValue, _) { + final cellDate = cellValue[0]; + return TCellGroup( + cells: [ + // 1. 自定义文案(cellBuilder,仅 showPopup 弹窗模式) + TCell( + title: '自定义文案', + arrow: true, + note: _formatYmd(textSelected), + onClick: (_) { + TCalendar.showPopup( + context, + titleWidget: const Text('请选择日期'), + initialValue: textSelected, + minDate: DateTime(2022, 1, 1), + maxDate: DateTime(2022, 2, 15), + onConfirm: (value) => customTextSelected.value = value, + cellBuilder: (context, cell) { + final isSpecial = cell.date.month == 2 && + map.keys.contains(cell.date.day); + final sub = isSpecial ? '¥100' : '¥60'; + final top = isSpecial ? map[cell.date.day] : null; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (top != null) + Text(top, + style: TextStyle( + fontSize: 9, + color: isSpecial + ? TTheme.of(context).errorColor6 + : null, + )), + Text( + cell.date.day.toString(), + style: TextStyle( + color: cell.selectType == DateSelectType.selected + ? TTheme.of(context).fontWhColor1 + : isSpecial + ? TTheme.of(context).errorColor6 + : null, + ), + ), + Text(sub, + style: TextStyle( + fontSize: 9, + color: cell.selectType == DateSelectType.selected + ? TTheme.of(context).fontWhColor1 + : isSpecial + ? TTheme.of(context).errorColor6 + : null, + )), + ], + ); + }, + ); + }, + ), + + // 2. 自定义确认按钮 + TCell( + title: '自定义按钮', + arrow: true, + note: _formatYmd(btnSelected), + onClick: (_) { + TCalendar.showPopup( + context, + titleWidget: const Text('请选择日期'), + initialValue: btnSelected, + confirmBtnBuilder: (onConfirm) => Padding( + padding: EdgeInsets.symmetric( + vertical: TTheme.of(context).spacer16), + child: TButton( + theme: TButtonTheme.danger, + shape: TButtonShape.round, + text: 'ok', + isBlock: true, + size: TButtonSize.large, + onTap: onConfirm, + ), + ), + onConfirm: (value) => customBtnSelected.value = value, + ); + }, + ), + + // 3. 自定义日期单元格(cellBuilder 回调) + TCell( + title: '自定义日期单元格', + arrow: true, + note: '${cellDate.year}-${cellDate.month}-${cellDate.day}', + onClick: (cell) { + TCalendar.showPopup( + context, + titleWidget: const Text('请选择日期'), + initialValue: cellValue, + cellHeight: 80, + onConfirm: (value) => customCellSelected.value = value, + cellBuilder: (context, cell) { + final today = DateTime.now(); + final isToday = cell.date == + DateTime(today.year, today.month, today.day); + + if (isToday && cell.selectType != DateSelectType.selected) { + return _CustomCellContainer( + color: TTheme.of(context).brandColor4, + child: const Text('今天', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white)), ); - if (day?.typeNotifier.value == DateSelectType.selected) { - day?.style = day.style - ?.copyWith(color: TTheme.of(context).fontWhColor1); - } } - } - return null; - }, - ), - ); - }, - ), - TCell( - title: '自定义按钮', - arrow: true, - onClick: (cell) { - late final TCalendarPopup calendar; - calendar = TCalendarPopup( - context, - visible: true, - confirmBtn: Padding( - padding: - EdgeInsets.symmetric(vertical: TTheme.of(context).spacer16), - child: TButton( - theme: TButtonTheme.danger, - shape: TButtonShape.round, - text: 'ok', - isBlock: true, - size: TButtonSize.large, - onTap: () { - print(calendar.selected); - calendar.close(); + if (cell.selectType == DateSelectType.selected) { + return _CustomCellContainer( + color: TTheme.of(context).successColor8, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('${cell.date.day}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white)), + const Text('已选', + style: + TextStyle(fontSize: 10, color: Colors.white)), + ], + ), + ); + } + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('${cell.date.day}', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), + const Text('自定义', style: TextStyle(fontSize: 8)), + ], + ); }, - ), - ), - child: TCalendar( - title: '请选择日期', - value: [DateTime.now().millisecondsSinceEpoch], - height: size.height * 0.6 + 176, - ), - ); - }, - ), - TCell( - title: '自定义日期区间', - arrow: true, - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - child: TCalendar( - title: '请选择日期', - minDate: DateTime(2000, 1, 1).millisecondsSinceEpoch, - maxDate: DateTime(3000, 1, 1).millisecondsSinceEpoch, - value: [DateTime(2024, 10, 1).millisecondsSinceEpoch], - height: size.height * 0.6 + 176, - ), + ); + }, + ), + ], + ); + }, ); }, - ), - ], + ); + }, ); } \ No newline at end of file diff --git a/tdesign-component/example/lib/lunar_data_source_example.dart b/tdesign-component/example/lib/lunar_data_source_example.dart index 63072e22f..063cd182b 100644 --- a/tdesign-component/example/lib/lunar_data_source_example.dart +++ b/tdesign-component/example/lib/lunar_data_source_example.dart @@ -1,16 +1,11 @@ -import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'package:lunar/lunar.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +import 'lunar_info.dart'; -/// 基于 lunar 库的农历数据源实现示例 -/// -/// 使用方法: -/// TCalendar( -/// dateType: TCalendarDateType.lunar, -/// dataSource: LunarDataSourceExample(), -/// ) +/// 基于 lunar 库的农历示例:副标题由 [getSubtitle] 或 [subtitleBuilder] 接入。 class LunarDataSourceExample extends TCalendarDataSource { - /// 将数字转换为中文数字 - static String _convertToChineseNumber(int number) { + static String convertToChineseNumber(int number) { const digits = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九']; return number .toString() @@ -19,8 +14,7 @@ class LunarDataSourceExample extends TCalendarDataSource { .join(); } - /// 获取农历月份中文名称 - static String _getLunarMonthName(int month, bool isLeapMonth) { + static String lunarMonthName(int month, bool isLeapMonth) { const months = [ '正月', '二月', @@ -39,8 +33,7 @@ class LunarDataSourceExample extends TCalendarDataSource { return isLeapMonth ? '闰$monthText' : monthText; } - /// 获取农历日期中文名称 - static String _getLunarDayName(int day) { + static String lunarDayName(int day) { const days = [ '初一', '初二', @@ -76,51 +69,56 @@ class LunarDataSourceExample extends TCalendarDataSource { return days[day - 1]; } - @override - TLunarInfo? getLunarInfo(DateTime solarDate) { + LunarInfo? getLunarInfo(DateTime solarDate) { try { final solar = Solar.fromDate(solarDate); final lunar = solar.getLunar(); - - return TLunarInfo( + + return LunarInfo( year: lunar.getYear(), month: lunar.getMonth().abs(), day: lunar.getDay(), isLeapMonth: lunar.getMonth() < 0, - yearText: _convertToChineseNumber(lunar.getYear()), - monthText: _getLunarMonthName(lunar.getMonth().abs(), lunar.getMonth() < 0), - dayText: _getLunarDayName(lunar.getDay()), + yearText: convertToChineseNumber(lunar.getYear()), + monthText: lunarMonthName(lunar.getMonth().abs(), lunar.getMonth() < 0), + dayText: lunarDayName(lunar.getDay()), ); } catch (e) { - print('农历转换错误: $e'); return null; } } @override - String formatDate( - DateTime date, - TCalendarDateType type, [ - TLunarInfo? lunarInfo, - ]) { - if (type == TCalendarDateType.solar) { - return '${date.year}年${date.month}月${date.day}日'; - } else { - if (lunarInfo != null) { - return '${lunarInfo.yearText}年 ${lunarInfo.monthText}${lunarInfo.dayText}'; - } - return '${date.year}年${date.month}月${date.day}日'; + String? getSubtitle(DateTime date) { + final lunar = getLunarInfo(date); + final festival = festivalOf(date, lunar); + if (festival != null && festival.isNotEmpty) { + return festival; } + final term = solarTermOf(date); + if (term != null && term.isNotEmpty) { + return term; + } + return lunar?.dayText; } - @override - String? getSolarTerm(DateTime date) { - // 节气功能暂未实现 - // lunar 包的不同版本 API 可能不同 - // 可以使用专门的节气计算库或查表法 - - // 以下是 2026 年部分节气示例数据(仅用于演示) - final key = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + String formatYear(int year, {bool lunar = false}) { + if (!lunar) { + return '$year年'; + } + return '${convertToChineseNumber(year)}年'; + } + + String formatMonth(int month, {bool lunar = false, bool isLeapMonth = false}) { + if (!lunar) { + return '$month月'; + } + return lunarMonthName(month, isLeapMonth); + } + + String? solarTermOf(DateTime date) { + final key = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; const solarTerms = { '2026-03-20': '春分', '2026-04-04': '清明', @@ -145,104 +143,18 @@ class LunarDataSourceExample extends TCalendarDataSource { return solarTerms[key]; } - @override - String? getFestival(DateTime date, [TLunarInfo? lunarInfo]) { - // 阳历节日 + String? festivalOf(DateTime date, [LunarInfo? lunarInfo]) { if (date.month == 1 && date.day == 1) return '元旦'; if (date.month == 2 && date.day == 14) return '情人节'; - if (date.month == 3 && date.day == 8) return '妇女节'; if (date.month == 5 && date.day == 1) return '劳动节'; - if (date.month == 5 && date.day == 4) return '青年节'; - if (date.month == 6 && date.day == 1) return '儿童节'; - if (date.month == 7 && date.day == 1) return '建党节'; - if (date.month == 8 && date.day == 1) return '建军节'; - if (date.month == 9 && date.day == 10) return '教师节'; if (date.month == 10 && date.day == 1) return '国庆节'; - if (date.month == 12 && date.day == 25) return '圣诞节'; - // 农历节日 if (lunarInfo != null) { if (lunarInfo.month == 1 && lunarInfo.day == 1) return '春节'; if (lunarInfo.month == 1 && lunarInfo.day == 15) return '元宵节'; - if (lunarInfo.month == 2 && lunarInfo.day == 2) return '龙抬头'; if (lunarInfo.month == 5 && lunarInfo.day == 5) return '端午节'; - if (lunarInfo.month == 7 && lunarInfo.day == 7) return '七夕节'; - if (lunarInfo.month == 7 && lunarInfo.day == 15) return '中元节'; if (lunarInfo.month == 8 && lunarInfo.day == 15) return '中秋节'; - if (lunarInfo.month == 9 && lunarInfo.day == 9) return '重阳节'; - if (lunarInfo.month == 12 && lunarInfo.day == 8) return '腊八节'; - if (lunarInfo.month == 12 && lunarInfo.day == 23) return '小年'; - // 除夕:农历十二月最后一天 - if (lunarInfo.month == 12 && (lunarInfo.day == 29 || lunarInfo.day == 30)) { - // 需要判断是否是该月最后一天,这里简化处理 - return '除夕'; - } } - return null; } - - @override - Map? getHolidayInfo(DateTime date) { - // 2026 年法定节假日和调休安排(根据国务院发布的实际安排) - // 这里提供示例数据,实际使用时应根据每年最新公布的假期安排更新 - - final key = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; - - // 法定节假日 - const holidays = { - // 元旦(2026年1月1-3日放假) - '2026-01-01': {'type': 'holiday', 'name': '元旦'}, - '2026-01-02': {'type': 'holiday', 'name': '元旦'}, - '2026-01-03': {'type': 'holiday', 'name': '元旦'}, - - // 春节(假设2026年1月29日-2月4日放假) - '2026-01-29': {'type': 'holiday', 'name': '春节'}, - '2026-01-30': {'type': 'holiday', 'name': '春节'}, - '2026-01-31': {'type': 'holiday', 'name': '春节'}, - '2026-02-01': {'type': 'holiday', 'name': '春节'}, - '2026-02-02': {'type': 'holiday', 'name': '春节'}, - '2026-02-03': {'type': 'holiday', 'name': '春节'}, - '2026-02-04': {'type': 'holiday', 'name': '春节'}, - - // 清明节(假设2026年4月4-6日放假) - '2026-04-04': {'type': 'holiday', 'name': '清明节'}, - '2026-04-05': {'type': 'holiday', 'name': '清明节'}, - '2026-04-06': {'type': 'holiday', 'name': '清明节'}, - - // 劳动节(假设2026年5月1-5日放假) - '2026-05-01': {'type': 'holiday', 'name': '劳动节'}, - '2026-05-02': {'type': 'holiday', 'name': '劳动节'}, - '2026-05-03': {'type': 'holiday', 'name': '劳动节'}, - '2026-05-04': {'type': 'holiday', 'name': '劳动节'}, - '2026-05-05': {'type': 'holiday', 'name': '劳动节'}, - - // 端午节(假设2026年6月25-27日放假) - '2026-06-25': {'type': 'holiday', 'name': '端午节'}, - '2026-06-26': {'type': 'holiday', 'name': '端午节'}, - '2026-06-27': {'type': 'holiday', 'name': '端午节'}, - - // 国庆节+中秋节(假设2026年10月1-8日放假) - '2026-10-01': {'type': 'holiday', 'name': '国庆节'}, - '2026-10-02': {'type': 'holiday', 'name': '国庆节'}, - '2026-10-03': {'type': 'holiday', 'name': '国庆节'}, - '2026-10-04': {'type': 'holiday', 'name': '国庆节'}, - '2026-10-05': {'type': 'holiday', 'name': '国庆节'}, - '2026-10-06': {'type': 'holiday', 'name': '中秋节'}, - '2026-10-07': {'type': 'holiday', 'name': '假期'}, - '2026-10-08': {'type': 'holiday', 'name': '假期'}, - }; - - // 调休工作日(假设数据,实际需根据国务院通知) - const workdays = { - '2026-01-04': {'type': 'workday', 'name': '补班'}, - '2026-01-24': {'type': 'workday', 'name': '补班'}, - '2026-02-07': {'type': 'workday', 'name': '补班'}, - '2026-04-26': {'type': 'workday', 'name': '补班'}, - '2026-09-27': {'type': 'workday', 'name': '补班'}, - '2026-10-10': {'type': 'workday', 'name': '补班'}, - }; - - return holidays[key] ?? workdays[key]; - } } diff --git a/tdesign-component/lib/src/components/calendar/t_lunar_date.dart b/tdesign-component/example/lib/lunar_info.dart similarity index 62% rename from tdesign-component/lib/src/components/calendar/t_lunar_date.dart rename to tdesign-component/example/lib/lunar_info.dart index e0904e30b..e44e75919 100644 --- a/tdesign-component/lib/src/components/calendar/t_lunar_date.dart +++ b/tdesign-component/example/lib/lunar_info.dart @@ -1,30 +1,20 @@ import 'package:flutter/foundation.dart'; -/// 农历日期信息模型 +/// 农历日期信息(仅示例用,业务请自行定义模型)。 +/// +/// 日历组件只要求 [TCalendarDataSource.getSubtitle] 返回字符串; +/// 本类演示如何从农历库组装副标题,非 `tdesign_flutter` 公共 API。 @immutable -class TLunarInfo { - /// 农历年份(数字) +class LunarInfo { final int year; - - /// 农历月份(数字,1-12) final int month; - - /// 农历日期(数字,1-30) final int day; - - /// 是否是闰月 final bool isLeapMonth; - - /// 年份文本(如:二〇二五) final String yearText; - - /// 月份文本(如:三月、闰三月) final String monthText; - - /// 日期文本(如:初七) final String dayText; - const TLunarInfo({ + const LunarInfo({ required this.year, required this.month, required this.day, @@ -34,7 +24,6 @@ class TLunarInfo { required this.dayText, }); - /// 获取完整的农历日期文本 String get fullText => '$yearText年 $monthText$dayText'; @override @@ -45,7 +34,7 @@ class TLunarInfo { if (identical(this, other)) { return true; } - return other is TLunarInfo && + return other is LunarInfo && other.year == year && other.month == month && other.day == day && @@ -59,12 +48,3 @@ class TLunarInfo { day.hashCode ^ isLeapMonth.hashCode; } - -/// 日历类型枚举 -enum TCalendarDateType { - /// 阳历(公历) - solar, - - /// 阴历(农历) - lunar, -} diff --git a/tdesign-component/example/lib/lunar_test_main.dart b/tdesign-component/example/lib/lunar_test_main.dart deleted file mode 100644 index 6a0f8276f..000000000 --- a/tdesign-component/example/lib/lunar_test_main.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; -import 'page/t_calendar_lunar_test.dart'; - -/// 农历日历功能快速测试入口 -/// -/// 运行方式: -/// cd /Users/JamesLiauw/Works/WorksMobile/tdesign-flutter/tdesign-component/example -/// ~/flutter/bin/flutter run -d chrome lib/lunar_test_main.dart -void main() { - runApp(const LunarTestApp()); -} - -class LunarTestApp extends StatelessWidget { - const LunarTestApp({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: '农历日历测试', - theme: ThemeData( - primarySwatch: Colors.blue, - useMaterial3: true, - ), - home: const TCalendarLunarTest(), - ); - } -} diff --git a/tdesign-component/example/lib/page/t_calendar_lunar_demo.dart b/tdesign-component/example/lib/page/t_calendar_lunar_demo.dart deleted file mode 100644 index 9e9925d41..000000000 --- a/tdesign-component/example/lib/page/t_calendar_lunar_demo.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:tdesign_flutter/tdesign_flutter.dart'; - -/// 农历日历演示页面 -/// 这是一个独立的演示页面,展示农历功能的基本用法 -class TCalendarLunarDemo extends StatefulWidget { - const TCalendarLunarDemo({Key? key}) : super(key: key); - - @override - State createState() => _TCalendarLunarDemoState(); -} - -class _TCalendarLunarDemoState extends State { - TCalendarDateType _dateType = TCalendarDateType.solar; - bool _showLunarInfo = false; - List _selectedDates = [DateTime.now().millisecondsSinceEpoch]; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('农历日历演示'), - backgroundColor: TTheme.of(context).brandNormalColor, - ), - body: Column( - children: [ - // 控制面板 - Container( - padding: const EdgeInsets.all(16), - color: TTheme.of(context).grayColor1, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '日历模式', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: InkWell( - onTap: () => setState(() => _dateType = TCalendarDateType.solar), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: _dateType == TCalendarDateType.solar ? Colors.blue : Colors.grey[200], - borderRadius: BorderRadius.circular(4), - ), - child: Center(child: Text('阳历模式', style: TextStyle(color: _dateType == TCalendarDateType.solar ? Colors.white : Colors.black))), - ), - ), - ), - Expanded( - child: InkWell( - onTap: () {}, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(4)), - child: Center(child: Text('农历模式', style: TextStyle(color: Colors.grey))), - ), - ), - ), - ], - ), - const SizedBox(height: 16), - TSwitch( - enable: _dateType == TCalendarDateType.solar, - isOn: _showLunarInfo, - size: TSwitchSize.large, - onChanged: (value) { - if (value != null && value != _showLunarInfo) { - setState(() { - _showLunarInfo = value; - }); - } - return true; - }, - ), - const SizedBox(height: 4), - Text( - '阳历模式下显示农历副标题', - style: TextStyle( - fontSize: 14, - color: TTheme.of(context).fontGyColor3, - ), - ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: TTheme.of(context).brandColor1, - borderRadius: BorderRadius.circular(6), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '💡 提示', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text( - _dateType == TCalendarDateType.lunar - ? '农历模式需要实现 TCalendarDataSource 接口\n请参考 lunar_data_source_example.dart' - : _showLunarInfo - ? '当前显示阳历日期,下方显示农历信息\n需要实现 TCalendarDataSource 接口' - : '当前仅显示阳历日期(默认模式)', - style: TextStyle( - fontSize: 12, - color: TTheme.of(context).fontGyColor3, - ), - ), - ], - ), - ), - ], - ), - ), - // 日历组件 - Expanded( - child: TCalendar( - type: CalendarType.single, - value: _selectedDates, - dateType: _dateType, - // dataSource: null, // 实际使用时需要提供数据源 - showLunarInfo: _showLunarInfo, - onChange: (dates) { - setState(() { - _selectedDates = dates; - }); - }, - ), - ), - // 选中日期显示 - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 8, - offset: const Offset(0, -2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '已选日期', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - _selectedDates.isEmpty - ? '未选择' - : _formatDate( - DateTime.fromMillisecondsSinceEpoch(_selectedDates[0])), - style: const TextStyle(fontSize: 18), - ), - ], - ), - ), - ], - ), - ); - } - - String _formatDate(DateTime date) { - return '${date.year}年${date.month}月${date.day}日'; - } -} diff --git a/tdesign-component/example/lib/page/t_calendar_lunar_example.dart b/tdesign-component/example/lib/page/t_calendar_lunar_example.dart deleted file mode 100644 index a0d1246e7..000000000 --- a/tdesign-component/example/lib/page/t_calendar_lunar_example.dart +++ /dev/null @@ -1,144 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:tdesign_flutter/tdesign_flutter.dart'; -import '../lunar_data_source_example.dart'; - -/// 农历日历示例页面 -/// -/// 展示如何使用 TCalendar 的农历功能 -class TCalendarLunarExample extends StatefulWidget { - const TCalendarLunarExample({Key? key}) : super(key: key); - - @override - State createState() => _TCalendarLunarExampleState(); -} - -class _TCalendarLunarExampleState extends State { - TCalendarDateType _dateType = TCalendarDateType.solar; - bool _showLunarInfo = true; // 默认显示农历信息 - List _selectedDates = []; - final _dataSource = LunarDataSourceExample(); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('农历日历示例'), - ), - body: Column( - children: [ - // 控制面板 - _buildControlPanel(), - - // 日历组件 - Expanded( - child: TCalendar( - dateType: _dateType, - dataSource: _dataSource, - showLunarInfo: _showLunarInfo, - value: _selectedDates, - onChange: (dates) { - setState(() { - _selectedDates = dates; - }); - }, - ), - ), - - // 选中日期显示 - _buildSelectedInfo(), - ], - ), - ); - } - - Widget _buildControlPanel() { - return Container( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '日历类型:', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: RadioListTile( - title: const Text('阳历'), - value: TCalendarDateType.solar, - groupValue: _dateType, - onChanged: (value) { - setState(() { - _dateType = value!; - }); - }, - ), - ), - Expanded( - child: RadioListTile( - title: const Text('农历'), - value: TCalendarDateType.lunar, - groupValue: _dateType, - onChanged: (value) { - setState(() { - _dateType = value!; - }); - }, - ), - ), - ], - ), - const SizedBox(height: 16), - SwitchListTile( - title: const Text('阳历模式下显示农历信息'), - value: _showLunarInfo, - onChanged: _dateType == TCalendarDateType.solar - ? (value) { - setState(() { - _showLunarInfo = value; - }); - } - : null, - ), - const Divider(), - ], - ), - ); - } - - Widget _buildSelectedInfo() { - if (_selectedDates.isEmpty) { - return Container( - padding: const EdgeInsets.all(16), - child: const Text('请选择日期'), - ); - } - - final date = DateTime.fromMillisecondsSinceEpoch(_selectedDates.first); - final dataSource = LunarDataSourceExample(); - final lunarInfo = dataSource.getLunarInfo(date); - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.grey[100], - border: Border(top: BorderSide(color: Colors.grey[300]!)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '已选择日期:', - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), - ), - const SizedBox(height: 8), - Text('阳历:${date.year}年${date.month}月${date.day}日'), - if (lunarInfo != null) - Text('农历:${lunarInfo.yearText}年 ${lunarInfo.monthText}${lunarInfo.dayText}'), - ], - ), - ); - } -} diff --git a/tdesign-component/example/lib/page/t_calendar_lunar_test.dart b/tdesign-component/example/lib/page/t_calendar_lunar_test.dart deleted file mode 100644 index 42dc59876..000000000 --- a/tdesign-component/example/lib/page/t_calendar_lunar_test.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:tdesign_flutter/tdesign_flutter.dart'; -import '../lunar_data_source_example.dart'; - -/// 农历日历功能快速测试页面 -class TCalendarLunarTest extends StatefulWidget { - const TCalendarLunarTest({Key? key}) : super(key: key); - - @override - State createState() => _TCalendarLunarTestState(); -} - -class _TCalendarLunarTestState extends State { - bool _showLunarInfo = true; - final _dataSource = LunarDataSourceExample(); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text( - '农历日历测试', - style: TextStyle(color: Colors.white), - ), - backgroundColor: const Color(0xFF0052D9), - ), - body: Column( - children: [ - // 快速切换 - Container( - padding: const EdgeInsets.all(16), - color: Colors.grey[100], - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - '显示农历信息', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - Switch( - value: _showLunarInfo, - onChanged: (value) { - setState(() { - _showLunarInfo = value; - }); - }, - activeColor: const Color(0xFF0052D9), - ), - ], - ), - ), - - // 提示信息 - Container( - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFE8F3FF), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: const Color(0xFF0052D9).withOpacity(0.2)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row( - children: [ - Icon(Icons.info_outline, color: Color(0xFF0052D9), size: 20), - SizedBox(width: 8), - Text( - '功能说明', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Color(0xFF0052D9), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - _showLunarInfo - ? '✅ 农历信息已启用\n每个日期下方会显示对应的农历日期' - : '❌ 农历信息已关闭\n仅显示阳历日期(默认模式)', - style: const TextStyle(fontSize: 13, height: 1.5), - ), - ], - ), - ), - - // 日历组件 - Expanded( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: TCalendar( - type: CalendarType.single, - dateType: TCalendarDateType.solar, - dataSource: _dataSource, - showLunarInfo: _showLunarInfo, - cellHeight: _showLunarInfo ? 80 : 60, // 增加到 80 以完全避免溢出 - value: [DateTime.now().millisecondsSinceEpoch], - onChange: (dates) { - if (dates.isNotEmpty) { - final date = DateTime.fromMillisecondsSinceEpoch(dates[0]); - final lunarInfo = _dataSource.getLunarInfo(date); - final solarTerm = _dataSource.getSolarTerm(date); - final festival = _dataSource.getFestival(date); - final holidayInfo = _dataSource.getHolidayInfo(date); - - // 构建消息 - final buffer = StringBuffer(); - buffer.write('选中日期:\n'); - buffer.write('阳历:${date.year}年${date.month}月${date.day}日\n'); - - if (lunarInfo != null) { - buffer.write('农历:${lunarInfo.yearText}年${lunarInfo.monthText}${lunarInfo.dayText}'); - } - - // 显示节气 - if (solarTerm != null && solarTerm.isNotEmpty) { - buffer.write('\n节气:$solarTerm'); - } - - // 显示节日 - if (festival != null && festival.isNotEmpty) { - buffer.write('\n节日:$festival'); - } - - // 显示假期信息 - if (holidayInfo != null && holidayInfo.isNotEmpty) { - buffer.write('\n假期:$holidayInfo'); - } - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(buffer.toString()), - duration: const Duration(seconds: 3), - ), - ); - } - }, - ), - ), - ), - - const SizedBox(height: 16), - ], - ), - ); - } -} diff --git a/tdesign-component/example/lib/page/t_calendar_page.dart b/tdesign-component/example/lib/page/t_calendar_page.dart index b111e6c3b..2391132f0 100644 --- a/tdesign-component/example/lib/page/t_calendar_page.dart +++ b/tdesign-component/example/lib/page/t_calendar_page.dart @@ -4,6 +4,14 @@ import '../../base/example_widget.dart'; import '../annotation/demo.dart'; import '../lunar_data_source_example.dart'; +/// TCalendar 日历组件示例页 +/// +/// 演示 [TCalendar] 的所有使用方式: +/// - **Popup 模式**:通过 [TCalendar.showPopup] 以弹窗形式展示日历 +/// - 单选、多选、区间选择、锚点定位 +/// - `popupOverlayBuilder` / `popupOverlayExpanded`(弹窗模式日历内容区底部浮层) +/// - **自定义样式**:文案、按钮、日期单元格 +/// - **农历日历**:结合 [TCalendarDataSource] 展示农历信息 class TCalendarPage extends StatelessWidget { const TCalendarPage({super.key}); @@ -25,29 +33,13 @@ class TCalendarPage extends StatelessWidget { ]), ExampleModule(title: '组件样式', children: [ ExampleItem( - desc: '可以自由定义想要的风格', + desc: '自定义文案、按钮、单元格', ignoreCode: true, center: false, builder: (BuildContext context) { return const CodeWrapper(builder: _buildStyle); }, ), - ExampleItem( - desc: '自定义日期单元格', - ignoreCode: true, - center: false, - builder: (BuildContext context) { - return const CodeWrapper(builder: _buildCustomCell); - }, - ), - ExampleItem( - desc: '不使用Popup', - ignoreCode: true, - center: false, - builder: (BuildContext context) { - return const CodeWrapper(builder: _buildBlock); - }, - ), ExampleItem( desc: '农历日历', ignoreCode: true, @@ -65,213 +57,467 @@ class TCalendarPage extends StatelessWidget { @Demo(group: 'calendar') Widget _buildSimple(BuildContext context) { - final size = MediaQuery.of(context).size; - final selected = ValueNotifier>( - [DateTime.now().millisecondsSinceEpoch + 30 * 24 * 60 * 60 * 1000]); - return ValueListenableBuilder( - valueListenable: selected, - builder: (context, value, child) { - final date = DateTime.fromMillisecondsSinceEpoch(value[0]); - return TCellGroup( - cells: [ - TCell( - title: '单个选择日历', - arrow: true, - note: '${date.year}-${date.month}-${date.day}', - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - onConfirm: (value) { - print('onConfirm:$value'); - selected.value = value; - }, - onClose: () { - print('onClose'); - }, - child: TCalendar( - title: '请选择日期', - value: value, - height: size.height * 0.6 + 176, - onCellClick: (value, type, tdate) { - print('onCellClick: $value'); - }, - onCellLongPress: (value, type, tdate) { - print('onCellLongPress: $value'); - }, - onHeaderClick: (index, week) { - print('onHeaderClick: $week'); - }, - onChange: (value) { - print('onChange: $value'); - }, - ), - ); - }, - ), - TCell( - title: '多个选择日历', - arrow: true, - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - child: TCalendar( - title: '请选择日期', - type: CalendarType.multiple, - value: [DateTime.now().millisecondsSinceEpoch], - height: size.height * 0.6 + 176, - ), - ); - }, + return const _SimpleDemo(); +} + +/// 「组件类型」演示容器 +/// +/// 包含 4 种 [TCalendar.showPopup] 弹窗模式: +/// 1. 单选 + 天气浮层(演示 popupOverlayBuilder / popupOverlayExpanded) +/// 2. 多选 + 已选汇总 bottom +/// 3. 区间选择 + 区间摘要 bottom +/// 4. 锚点定位到指定月份 +class _SimpleDemo extends StatelessWidget { + const _SimpleDemo(); + + @override + Widget build(BuildContext context) { + return const Column( + children: [ + _SingleCalendarCell(), + _MultipleCalendarCell(), + _RangeCalendarCell(), + _AnchorCalendarCell(), + ], + ); + } +} + +// ========================= 1. 单选 + 天气 ========================= +/// 单选日历 + bottom 天气面板 +/// +/// 演示 [TCalendar.showPopup] 的 popupOverlayBuilder / popupOverlayExpanded 用法 +/// (底部区域仅弹窗模式,经 [TCalendarInherited] 注入,不可传给内嵌 [TCalendar]): +/// - 选中日期后展开 bottom 区域显示天气信息 +/// - 确认后回传选中值 +class _SingleCalendarCell extends StatefulWidget { + const _SingleCalendarCell(); + @override + State<_SingleCalendarCell> createState() => _SingleCalendarCellState(); +} + +class _SingleCalendarCellState extends State<_SingleCalendarCell> { + List _selected = const []; + final ValueNotifier _expanded = ValueNotifier(false); + final Map _cache = {}; + + @override + void dispose() { + _expanded.dispose(); + super.dispose(); + } + + _WeatherData _weatherFor(DateTime date) { + final key = date.year * 10000 + date.month * 100 + date.day; + return _cache.putIfAbsent(key, () => _WeatherData.random(key)); + } + + @override + Widget build(BuildContext context) { + return TCell( + title: '单个选择日历', + arrow: true, + note: _formatYmd(_selected), + onClick: (_) { + _expanded.value = _selected.isNotEmpty; + TCalendar.showPopup( + context, + titleWidget: const Text('请选择日期'), + initialValue: _selected, + popupOverlayExpanded: _expanded, + onCellClick: (value, selectType, tdate) => _expanded.value = true, + popupOverlayBuilder: (bCtx, dates) { + final d = dates.isEmpty ? DateTime.now() : dates.first; + return _WeatherPanel(date: d, weather: _weatherFor(d)); + }, + onConfirm: (value) => setState(() => _selected = value), + onClose: () => _expanded.value = false, + ); + }, + ); + } +} + +// ========================= 2. 多选 ========================= +/// 多选日历 + bottom 已选汇总 +/// +/// 演示 [CalendarType.multiple] 多选模式,popupOverlayBuilder 区域展示已选日期列表。 +class _MultipleCalendarCell extends StatefulWidget { + const _MultipleCalendarCell(); + @override + State<_MultipleCalendarCell> createState() => _MultipleCalendarCellState(); +} + +class _MultipleCalendarCellState extends State<_MultipleCalendarCell> { + List _dates = const []; + + @override + Widget build(BuildContext context) { + return TCell( + title: '多个选择日历', + arrow: true, + note: _dates.isEmpty ? '--' : '已选 ${_dates.length} 天', + onClick: (_) { + TCalendar.showPopup( + context, + titleWidget: const Text('请选择日期'), + type: CalendarType.multiple, + initialValue: _dates.isEmpty ? [DateTime.now()] : _dates, + popupOverlayBuilder: (bCtx, dates) => _MultipleSummary(selected: dates), + onConfirm: (value) => setState(() => _dates = value), + ); + }, + ); + } +} + +// ========================= 3. 区间 ========================= +/// 区间选择日历 + bottom 区间摘要 +/// +/// 演示 [CalendarType.range] 区间模式,popupOverlayBuilder 区域展示开始/结束日期及天数。 +class _RangeCalendarCell extends StatefulWidget { + const _RangeCalendarCell(); + @override + State<_RangeCalendarCell> createState() => _RangeCalendarCellState(); +} + +class _RangeCalendarCellState extends State<_RangeCalendarCell> { + late List _dates = [ + DateTime.now(), + DateTime.now().add(const Duration(days: 6)), + ]; + + @override + Widget build(BuildContext context) { + return TCell( + title: '区间选择日历', + arrow: true, + note: _dates.length >= 2 + ? '${_formatMd(_dates.first)} ~ ${_formatMd(_dates[1])}' + : '--', + onClick: (_) { + TCalendar.showPopup( + context, + titleWidget: const Text('请选择日期区间'), + type: CalendarType.range, + initialValue: _dates, + popupOverlayBuilder: (bCtx, dates) => _RangeSummary(selected: dates), + onConfirm: (value) => setState(() => _dates = value), + ); + }, + ); + } +} + +// ========================= 4. 锚点 ========================= +/// 锚点定位 +/// +/// 演示 [TCalendar.showPopup] 的 anchorDate 参数, +/// 弹出日历时自动滚动到指定月份。 +class _AnchorCalendarCell extends StatefulWidget { + const _AnchorCalendarCell(); + @override + State<_AnchorCalendarCell> createState() => _AnchorCalendarCellState(); +} + +class _AnchorCalendarCellState extends State<_AnchorCalendarCell> { + List _selected = [DateTime(2026, 5, 1)]; + + @override + Widget build(BuildContext context) { + return TCell( + title: '添加锚点', + arrow: true, + note: _formatYmd(_selected), + onClick: (_) { + TCalendar.showPopup( + context, + titleWidget: const Text('请选择日期'), + anchorDate: DateTime(2026), + initialValue: _selected, + onConfirm: (dates) => setState(() => _selected = dates), + ); + }, + ); + } +} + +// ===== 顶层格式化辅助函数 ===== +String _formatYmd(List dates) { + if (dates.isEmpty) { + return '--'; + } + final d = dates.first; + return '${d.year}-${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; +} + +String _formatMd(DateTime d) { + return '${d.month}/${d.day}'; +} + +String _formatYmdFull(DateTime d) { + return '${d.year}-${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; +} + +// ===== 共用 bottom 面板装饰 ===== +BoxDecoration _bottomCardDecoration(BuildContext context) => BoxDecoration( + color: Theme.of(context).colorScheme.surface, + boxShadow: const [ + BoxShadow( + color: Color.fromRGBO(0, 0, 0, 0.04), + blurRadius: 12, + offset: Offset(0, -2), + ), + ], + ); + +// ===== 天气数据模型 ===== +class _WeatherData { + const _WeatherData({ + required this.icon, + required this.temp, + required this.humidity, + required this.wind, + required this.windLevel, + }); + + final String icon; + final int temp; + final int humidity; + final String wind; + final int windLevel; + + factory _WeatherData.random(int seed) { + const weathers = ['☀️ 晴', '⛅ 多云', '🌧️ 小雨', '⛈️ 雷阵雨', '❄️ 小雪', '🌫️ 雾']; + const winds = ['北风', '南风', '东风', '西风', '微风']; + return _WeatherData( + icon: weathers[seed % weathers.length], + temp: -5 + (seed % 30), + humidity: 30 + (seed % 50), + wind: winds[seed % winds.length], + windLevel: 1 + (seed % 5), + ); + } +} + +// ===== 拆分出的私有 widget ===== + +/// 自定义单元格容器:统一圆角 + 填充色 + 撑满约束 +class _CustomCellContainer extends StatelessWidget { + const _CustomCellContainer({required this.color, required this.child}); + + final Color color; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + constraints: const BoxConstraints.expand(), + alignment: Alignment.center, + child: child, + ); + } +} + +class _WeatherPanel extends StatelessWidget { + const _WeatherPanel({required this.date, required this.weather}); + + final DateTime date; + final _WeatherData weather; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: _bottomCardDecoration(context), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${date.year}-${date.month}-${date.day}', + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text(weather.icon, style: const TextStyle(fontSize: 22)), + ], ), - TCell( - title: '区间选择日历', - arrow: true, - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - child: TCalendar( - title: '请选择日期区间', - type: CalendarType.range, - value: [ - DateTime.now().millisecondsSinceEpoch, - DateTime.now() - .add(const Duration(days: 6)) - .millisecondsSinceEpoch, - ], - height: size.height * 0.6 + 176, - ), - ); - }, + const SizedBox(width: 24), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _IconRow(icon: Icons.thermostat, text: '${weather.temp}°C'), + const SizedBox(height: 4), + _IconRow(icon: Icons.water_drop, text: '${weather.humidity}%'), + const SizedBox(height: 4), + _IconRow( + icon: Icons.air, + text: '${weather.wind} ${weather.windLevel} 级'), + ], + ), ), - TCell( - title: '单个选择日历和时间', - arrow: true, - note: - '${date.year}-${date.month}-${date.day} ${date.hour}:${date.minute}', - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - onConfirm: (value) { - print('onConfirm:$value'); - selected.value = value; - }, - onClose: () { - print('onClose'); - }, - child: TCalendar( - title: '请选择日期和时间', - value: value, - height: size.height * 0.92, - useTimePicker: true, - // pickerHeight: 100, - // pickerItemCount: 2, - onCellClick: (value, type, tdate) { - print('onCellClick: $value'); - }, - onCellLongPress: (value, type, tdate) { - print('onCellLongPress: $value'); - }, - onHeaderClick: (index, week) { - print('onHeaderClick: $week'); - }, - onChange: (value) { - print('onChange: $value'); - }, - ), - ); - }, + ], + ), + ); + } +} + +class _IconRow extends StatelessWidget { + const _IconRow({required this.icon, required this.text}); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 14), + const SizedBox(width: 4), + Text(text), + ], + ); + } +} + +class _MultipleSummary extends StatelessWidget { + const _MultipleSummary({required this.selected}); + + final List selected; + + @override + Widget build(BuildContext context) { + final dates = [...selected]..sort(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: _bottomCardDecoration(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('已选择 ${dates.length} 天', + style: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 6, + children: dates + .map((d) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: TTheme.of(context).brandColor1, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _formatYmdFull(d), + style: TextStyle( + fontSize: 12, + color: TTheme.of(context).brandColor7), + ), + )) + .toList(), ), - TCell( - title: '区间选择日历和时间', - arrow: true, - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - onConfirm: (value) { - print('onConfirm: $value'); - }, - onClose: () { - print('onClose'); - }, - child: TCalendar( - title: '请选择日期和时间区间', - height: size.height * 0.92, - type: CalendarType.range, - value: [ - DateTime.now().millisecondsSinceEpoch, - DateTime.now() - .add(const Duration(days: 3)) - .millisecondsSinceEpoch, - ], - useTimePicker: true, - onCellClick: (value, type, tdate) { - print('onCellClick: $value'); - }, - onCellLongPress: (value, type, tdate) { - print('onCellLongPress: $value'); - }, - onHeaderClick: (index, week) { - print('onHeaderClick: $week'); - }, - onChange: (value) { - print('onChange: $value'); - }, - ), - ); - }, + ], + ), + ); + } +} + +class _RangeSummary extends StatelessWidget { + const _RangeSummary({required this.selected}); + + final List selected; + + @override + Widget build(BuildContext context) { + final hasStart = selected.isNotEmpty; + final hasEnd = selected.length >= 2; + final days = hasEnd + ? selected[1].difference(selected[0]).inDays + 1 + : (hasStart ? 1 : 0); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: _bottomCardDecoration(context), + child: Row( + children: [ + Expanded( + child: _RangeSegment( + label: '开始', + value: hasStart ? _formatYmdFull(selected[0]) : null), ), - TCell( - title: '添加锚点', - arrow: true, - note: '${date.year}-${date.month}-${date.day}', - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - onConfirm: (value) { - print('onConfirm:$value'); - selected.value = value; - }, - onClose: () { - print('onClose'); - }, - child: TCalendar( - title: '请选择日期', - minDate: DateTime(2022, 1, 1).millisecondsSinceEpoch, - maxDate: DateTime(2028, 2, 15).millisecondsSinceEpoch, - anchorDate: DateTime(2026, 5), - value: value, - height: size.height * 0.6 + 176, - onCellClick: (value, type, tdate) { - print('onCellClick: $value'); - }, - onCellLongPress: (value, type, tdate) { - print('onCellLongPress: $value'); - }, - onHeaderClick: (index, week) { - print('onHeaderClick: $week'); - }, - onChange: (value) { - print('onChange: $value'); - }, - ), - ); - }, + Icon(Icons.arrow_forward, + size: 16, color: TTheme.of(context).fontGyColor3), + const SizedBox(width: 12), + Expanded( + child: _RangeSegment( + label: '结束', + value: hasEnd ? _formatYmdFull(selected[1]) : null), ), + if (days > 0) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: TTheme.of(context).brandColor1, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '共 $days 天', + style: TextStyle( + fontSize: 12, color: TTheme.of(context).brandColor7), + ), + ), ], - ); - }, - ); + ), + ); + } } +class _RangeSegment extends StatelessWidget { + const _RangeSegment({required this.label, required this.value}); + + final String label; + final String? value; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle( + fontSize: 12, color: TTheme.of(context).fontGyColor3)), + const SizedBox(height: 2), + Text( + value ?? '--', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: value != null + ? TTheme.of(context).fontGyColor1 + : TTheme.of(context).fontGyColor3, + ), + ), + ], + ); + } +} + +/// 「组件样式 - 自定义文案 / 按钮 / 单元格」 @Demo(group: 'calendar') Widget _buildStyle(BuildContext context) { - final size = MediaQuery.of(context).size; const map = { 1: '初一', 2: '初二', @@ -279,568 +525,529 @@ Widget _buildStyle(BuildContext context) { 14: '情人节', 15: '元宵节', }; - return TCellGroup( - cells: [ - TCell( - title: '自定义文案', - arrow: true, - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - child: TCalendar( - title: '请选择日期', - height: size.height * 0.6 + 176, - minDate: DateTime(2022, 1, 1).millisecondsSinceEpoch, - maxDate: DateTime(2022, 2, 15).millisecondsSinceEpoch, - format: (day) { - day?.suffix = '¥60'; - if (day?.date.month == 2) { - if (map.keys.contains(day?.date.day)) { - day?.suffix = '¥100'; - day?.prefix = map[day.date.day]; - day?.style = TextStyle( - fontSize: TTheme.of(context).fontTitleMedium?.size, - height: TTheme.of(context).fontTitleMedium?.height, - fontWeight: - TTheme.of(context).fontTitleMedium?.fontWeight, - color: TTheme.of(context).errorColor6, - ); - if (day?.typeNotifier.value == DateSelectType.selected) { - day?.style = day.style - ?.copyWith(color: TTheme.of(context).fontWhColor1); - } - } - } - return null; - }, - ), - ); - }, - ), - TCell( - title: '自定义按钮', - arrow: true, - onClick: (cell) { - late final TCalendarPopup calendar; - calendar = TCalendarPopup( - context, - visible: true, - confirmBtn: Padding( - padding: - EdgeInsets.symmetric(vertical: TTheme.of(context).spacer16), - child: TButton( - theme: TButtonTheme.danger, - shape: TButtonShape.round, - text: 'ok', - isBlock: true, - size: TButtonSize.large, - onTap: () { - print(calendar.selected); - calendar.close(); - }, - ), - ), - child: TCalendar( - title: '请选择日期', - value: [DateTime.now().millisecondsSinceEpoch], - height: size.height * 0.6 + 176, - ), - ); - }, - ), - TCell( - title: '自定义日期区间', - arrow: true, - onClick: (cell) { - TCalendarPopup( - context, - visible: true, - child: TCalendar( - title: '请选择日期', - minDate: DateTime(2000, 1, 1).millisecondsSinceEpoch, - maxDate: DateTime(3000, 1, 1).millisecondsSinceEpoch, - value: [DateTime(2024, 10, 1).millisecondsSinceEpoch], - height: size.height * 0.6 + 176, - ), - ); - }, - ), - ], - ); -} -@Demo(group: 'calendar') -Widget _buildBlock(BuildContext context) { - final size = MediaQuery.of(context).size; - final selected = ValueNotifier>( - [DateTime.now().millisecondsSinceEpoch + 30 * 24 * 60 * 60 * 1000], - ); - return Column( - // spacing: TTheme.of(context).spacer16, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - // spacing: TTheme.of(context).spacer16, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TButton( - text: '加一个月', - theme: TButtonTheme.primary, - onTap: () { - selected.value = [selected.value[0] + 30 * 24 * 60 * 60 * 1000]; + final customTextSelected = + ValueNotifier>([DateTime(2022, 1, 15)]); + final customBtnSelected = + ValueNotifier>([DateTime.now()]); + final customCellSelected = ValueNotifier>( + [DateTime.now().add(const Duration(days: 30))]); + + return ValueListenableBuilder( + valueListenable: customTextSelected, + builder: (context, textSelected, _) { + return ValueListenableBuilder( + valueListenable: customBtnSelected, + builder: (context, btnSelected, _) { + return ValueListenableBuilder( + valueListenable: customCellSelected, + builder: (context, cellValue, _) { + final cellDate = cellValue[0]; + return TCellGroup( + cells: [ + // 1. 自定义文案(cellBuilder,仅 showPopup 弹窗模式) + TCell( + title: '自定义文案', + arrow: true, + note: _formatYmd(textSelected), + onClick: (_) { + TCalendar.showPopup( + context, + titleWidget: const Text('请选择日期'), + initialValue: textSelected, + minDate: DateTime(2022, 1, 1), + maxDate: DateTime(2022, 2, 15), + onConfirm: (value) => customTextSelected.value = value, + cellBuilder: (context, cell) { + final isSpecial = cell.date.month == 2 && + map.keys.contains(cell.date.day); + final sub = isSpecial ? '¥100' : '¥60'; + final top = isSpecial ? map[cell.date.day] : null; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (top != null) + Text(top, + style: TextStyle( + fontSize: 9, + color: isSpecial + ? TTheme.of(context).errorColor6 + : null, + )), + Text( + cell.date.day.toString(), + style: TextStyle( + color: cell.selectType == DateSelectType.selected + ? TTheme.of(context).fontWhColor1 + : isSpecial + ? TTheme.of(context).errorColor6 + : null, + ), + ), + Text(sub, + style: TextStyle( + fontSize: 9, + color: cell.selectType == DateSelectType.selected + ? TTheme.of(context).fontWhColor1 + : isSpecial + ? TTheme.of(context).errorColor6 + : null, + )), + ], + ); + }, + ); }, ), - const SizedBox(width: 16), - TButton( - text: '减一个月', - theme: TButtonTheme.primary, - onTap: () { - selected.value = [selected.value[0] - 30 * 24 * 60 * 60 * 1000]; + + // 2. 自定义确认按钮 + TCell( + title: '自定义按钮', + arrow: true, + note: _formatYmd(btnSelected), + onClick: (_) { + TCalendar.showPopup( + context, + titleWidget: const Text('请选择日期'), + initialValue: btnSelected, + confirmBtnBuilder: (onConfirm) => Padding( + padding: EdgeInsets.symmetric( + vertical: TTheme.of(context).spacer16), + child: TButton( + theme: TButtonTheme.danger, + shape: TButtonShape.round, + text: 'ok', + isBlock: true, + size: TButtonSize.large, + onTap: onConfirm, + ), + ), + onConfirm: (value) => customBtnSelected.value = value, + ); }, ), - ], - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: selected, - builder: (context, value, child) { - return TCalendar( - title: '请选择日期', - value: value, - height: size.height * 0.6 + 176, - animateTo: true, - // 不使用popup时,useSafeArea无效 - useSafeArea: true, - ); - }, - ), - ], - ); -} -@Demo(group: 'calendar') -Widget _buildCustomCell(BuildContext context) { - final size = MediaQuery.of(context).size; - final selected = ValueNotifier>( - [DateTime.now().millisecondsSinceEpoch + 30 * 24 * 60 * 60 * 1000]); - return ValueListenableBuilder( - valueListenable: selected, - builder: (context, value, child) { - final date = DateTime.fromMillisecondsSinceEpoch(value[0]); - return TCellGroup( - cells: [ + // 3. 自定义日期单元格(cellBuilder 回调) TCell( title: '自定义日期单元格', arrow: true, - note: '${date.year}-${date.month}-${date.day}', + note: '${cellDate.year}-${cellDate.month}-${cellDate.day}', onClick: (cell) { - TCalendarPopup( + TCalendar.showPopup( context, - visible: true, - onConfirm: (value) { - print('onConfirm:$value'); - selected.value = value; - }, - onClose: () { - print('onClose'); - }, - child: TCalendar( - title: '请选择日期', - value: value, - cellHeight: 80, - height: size.height * 0.6 + 176, - onCellClick: (value, type, tdate) { - print('onCellClick: $value'); - }, - onCellLongPress: (value, type, tdate) { - print('onCellLongPress: $value'); - }, - onHeaderClick: (index, week) { - print('onHeaderClick: $week'); - }, - onChange: (value) { - print('onChange: $value'); - }, - cellWidget: (context, tdate, selectType) { - final today = DateTime.now(); - //当前日期的自定义实现 - if (tdate.date.millisecondsSinceEpoch == - DateTime(today.year, today.month, today.day) - .millisecondsSinceEpoch && - selectType != DateSelectType.selected) { - return Container( - decoration: BoxDecoration( - color: TTheme.of(context).brandColor4, - borderRadius: BorderRadius.all(Radius.circular(6)), - ), - constraints: const BoxConstraints( - minWidth: 0, // 最小宽度为0 - maxWidth: double.infinity, // 最大宽度无限 - minHeight: 0, // 最小高度为0 - maxHeight: double.infinity), - child: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('今天', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white)), - ], - ), - ); - } - if (selectType == DateSelectType.selected) { - return Container( - decoration: BoxDecoration( - color: TTheme.of(context).successColor8, - borderRadius: BorderRadius.all(Radius.circular(6)), - ), - constraints: const BoxConstraints( - minWidth: 0, // 最小宽度为0 - maxWidth: double.infinity, // 最大宽度无限 - minHeight: 0, // 最小高度为0 - maxHeight: double.infinity), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('${tdate.date.day}', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white)), - const Text('文案文案', - style: TextStyle( - fontSize: 6, color: Colors.white)), - const Text('自定义', - style: TextStyle( - fontSize: 12, color: Colors.white)), - ], - ), - ); - } - return Column( + titleWidget: const Text('请选择日期'), + initialValue: cellValue, + cellHeight: 80, + onConfirm: (value) => customCellSelected.value = value, + cellBuilder: (context, cell) { + final today = DateTime.now(); + final isToday = cell.date == + DateTime(today.year, today.month, today.day); + + if (isToday && cell.selectType != DateSelectType.selected) { + return _CustomCellContainer( + color: TTheme.of(context).brandColor4, + child: const Text('今天', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white)), + ); + } + if (cell.selectType == DateSelectType.selected) { + return _CustomCellContainer( + color: TTheme.of(context).successColor8, + child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('${tdate.date.day}', + Text('${cell.date.day}', style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), - const Text('文案文案', style: TextStyle(fontSize: 8)), - const Text('自定义', style: TextStyle(fontSize: 8)), + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white)), + const Text('已选', + style: + TextStyle(fontSize: 10, color: Colors.white)), ], - ); - }), + ), + ); + } + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('${cell.date.day}', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), + const Text('自定义', style: TextStyle(fontSize: 8)), + ], + ); + }, ); }, ), - ], + ], + ); + }, + ); + }, ); }, ); } +/// 「组件样式 - 农历日历」 +/// +/// 非弹窗内嵌模式,通过 [TCalendarDataSource.getSubtitle] 展示农历副标题, +/// 支持月份切换、年份/月份弹窗选择。 @Demo(group: 'calendar') Widget _buildLunar(BuildContext context) { - final size = MediaQuery.of(context).size; - final dataSource = LunarDataSourceExample(); - - // 当前月份状态 - final currentMonth = ValueNotifier( - DateTime(DateTime.now().year, DateTime.now().month, 1), - ); - - // 农历开关状态 - final showLunarInfo = ValueNotifier(true); - - // 选中日期 - final selectedDate = ValueNotifier>([ - DateTime.now().millisecondsSinceEpoch, - ]); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 控制栏 - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.grey.shade50, - border: Border( - bottom: BorderSide(color: Colors.grey.shade200), - ), + return const _LunarCalendarDemo(); +} + +/// 农历日历内嵌演示 +/// +/// 控制栏(_LunarControlBar)与日历(TCalendar)分离: +/// - 滑动日历时 onMonthChange 只更新控制栏,不重建日历,避免跳动 +/// - 点击控制栏导航时才更新 anchorDate,驱动日历滚动 +class _LunarCalendarDemo extends StatefulWidget { + const _LunarCalendarDemo(); + + @override + State<_LunarCalendarDemo> createState() => _LunarCalendarDemoState(); +} + +class _LunarCalendarDemoState extends State<_LunarCalendarDemo> { + final _dataSource = LunarDataSourceExample(); + + // 日历可用日期范围。年份/月份选择器都基于这两个常量约束, + // 避免越界导致组件层 clamp 兜底(视觉上会卡在端点月份)。 + static final DateTime _minDate = DateTime(2020, 1, 1); + static final DateTime _maxDate = DateTime(2030, 12, 31); + + DateTime? _anchorDate; + int _anchorRevision = 0; + List _selected = [DateTime.now()]; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _LunarControlBar( + key: _LunarControlBar.monthKey, + dataSource: _dataSource, + minDate: _minDate, + maxDate: _maxDate, + onNavigate: (anchor) { + setState(() { + _anchorDate = anchor; + _anchorRevision++; + }); + }, ), - child: ValueListenableBuilder( - valueListenable: currentMonth, - builder: (context, month, child) { - // 获取当前月份的农历信息 - final lunarInfo = dataSource.getLunarInfo(month); - final lunarMonth = lunarInfo != null - ? '${lunarInfo.yearText}年 ${lunarInfo.monthText}' - : ''; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 农历年月显示 - if (lunarMonth.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - lunarMonth, - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade700, - fontWeight: FontWeight.w500, - ), - ), - ), - // 按钮行 - Row( - children: [ - // 上一月按钮 - TButton( - text: '上一月', - size: TButtonSize.small, - theme: TButtonTheme.primary, - onTap: () { - currentMonth.value = DateTime( - month.year, - month.month - 1, - 1, - ); - selectedDate.value = [currentMonth.value.millisecondsSinceEpoch]; - }, - ), - const SizedBox(width: 8), - // 年份选择 - Expanded( - child: TButton( - text: '${month.year}年', - size: TButtonSize.small, - theme: TButtonTheme.defaultTheme, - onTap: () async { - final year = await showModalBottomSheet( - context: context, - builder: (context) { - return SizedBox( - height: 300, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - '选择年份', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: ListView.builder( - itemCount: 50, - itemBuilder: (context, index) { - final year = DateTime.now().year - 10 + index; - final isSelected = year == month.year; - return ListTile( - title: Text( - '$year年', - style: TextStyle( - color: isSelected ? Colors.blue : null, - fontWeight: isSelected ? FontWeight.bold : null, - ), - ), - onTap: () => Navigator.pop(context, year), - ); - }, - ), - ), - ], - ), - ); - }, - ); - if (year != null) { - currentMonth.value = DateTime(year, month.month, 1); - selectedDate.value = [currentMonth.value.millisecondsSinceEpoch]; - } - }, - ), - ), - const SizedBox(width: 8), - // 月份选择 - Expanded( - child: TButton( - text: '${month.month}月', - size: TButtonSize.small, - theme: TButtonTheme.defaultTheme, - onTap: () async { - final selectedMonth = await showModalBottomSheet( - context: context, - builder: (context) { - return SizedBox( - height: 400, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - '选择月份', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - child: GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - ), - itemCount: 12, - itemBuilder: (context, index) { - final m = index + 1; - final isSelected = m == month.month; - return InkWell( - onTap: () => Navigator.pop(context, m), - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: isSelected ? Colors.blue : Colors.grey.shade200, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '$m月', - style: TextStyle( - color: isSelected ? Colors.white : Colors.black, - fontWeight: isSelected ? FontWeight.bold : null, - ), - ), - ), - ); - }, - ), - ), - ], - ), - ); - }, - ); - if (selectedMonth != null) { - currentMonth.value = DateTime(month.year, selectedMonth, 1); - selectedDate.value = [currentMonth.value.millisecondsSinceEpoch]; - } - }, - ), - ), - const SizedBox(width: 8), - // 下一月按钮 - TButton( - text: '下一月', - size: TButtonSize.small, - theme: TButtonTheme.primary, - onTap: () { - currentMonth.value = DateTime( - month.year, - month.month + 1, - 1, - ); - selectedDate.value = [currentMonth.value.millisecondsSinceEpoch]; - }, - ), - const SizedBox(width: 16), - // 农历开关 - ValueListenableBuilder( - valueListenable: showLunarInfo, - builder: (context, show, child) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '农历', - style: TextStyle(fontSize: 12, color: Colors.grey.shade700), - ), - Switch( - value: show, - onChanged: (value) { - showLunarInfo.value = value; - }, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ], - ); - }, - ), - ], - ), - ], - ); + + TCalendar( + type: CalendarType.single, + minDate: _minDate, + maxDate: _maxDate, + initialValue: _selected, + anchorDate: _anchorDate, + anchorRevision: _anchorRevision, + animateTo: true, + dataSource: _dataSource, + onMonthChange: (month) { + // 只通知控制栏更新显示,不 setState 本身 → 日历不重建 + _LunarControlBar.monthKey.currentState + ?.updateMonth(DateTime(month.year, month.month, 1)); }, + onChange: (value) => setState(() => _selected = value), ), + ], + ); + } +} + +/// 农历日历控制栏 +/// +/// 独立管理 _currentMonth 状态,滑动日历时只更新本 Widget, +/// 不触发上层日历重建,避免跳动。 +class _LunarControlBar extends StatefulWidget { + const _LunarControlBar({ + super.key, + required this.dataSource, + required this.minDate, + required this.maxDate, + required this.onNavigate, + }); + + final LunarDataSourceExample dataSource; + final DateTime minDate; + final DateTime maxDate; + final ValueChanged onNavigate; + + /// 全局 Key,供父 Widget 通过 currentState.updateMonth() 同步月份 + static final GlobalKey<_LunarControlBarState> monthKey = + GlobalKey<_LunarControlBarState>(); + + @override + State<_LunarControlBar> createState() => _LunarControlBarState(); +} + +class _LunarControlBarState extends State<_LunarControlBar> { + late DateTime _currentMonth; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _currentMonth = _clampMonth(DateTime(now.year, now.month, 1)); + } + + /// 将任意 (year, month) clamp 到 [minDate, maxDate] 区间内。 + DateTime _clampMonth(DateTime date) { + final minKey = widget.minDate.year * 12 + widget.minDate.month; + final maxKey = widget.maxDate.year * 12 + widget.maxDate.month; + final key = (date.year * 12 + date.month).clamp(minKey, maxKey); + final year = (key - 1) ~/ 12; + final month = (key - 1) % 12 + 1; + return DateTime(year, month, 1); + } + + bool _canGoPrev() { + final cur = _currentMonth.year * 12 + _currentMonth.month; + final minKey = widget.minDate.year * 12 + widget.minDate.month; + return cur > minKey; + } + + bool _canGoNext() { + final cur = _currentMonth.year * 12 + _currentMonth.month; + final maxKey = widget.maxDate.year * 12 + widget.maxDate.month; + return cur < maxKey; + } + + /// 由日历 onMonthChange 回调驱动,仅更新显示。 + /// + /// 注意:日历组件(TCalendarBody)内部已对程序化滚动期间的 onMonthChange 做静默, + /// 因此这里收到的回调只会是「用户手势滑动」或「滚动落定后的最终月份」, + /// 无需再做额外的中间值屏蔽。 + void updateMonth(DateTime month) { + if (_currentMonth.year == month.year && _currentMonth.month == month.month) { + return; + } + setState(() => _currentMonth = month); + } + + void _navigateTo(DateTime month) { + final clamped = _clampMonth(month); + // 命中相同月份时直接返回,避免触发上层无意义重建。 + if (_currentMonth.year == clamped.year && + _currentMonth.month == clamped.month) { + return; + } + // 立即更新控制栏显示,用户感知零延迟; + // 日历组件会自行屏蔽随后程序化滚动期间的中间月份回调。 + setState(() => _currentMonth = clamped); + widget.onNavigate(DateTime(clamped.year, clamped.month, 15)); + } + + Future _pickYear() async { + final minYear = widget.minDate.year; + final maxYear = widget.maxDate.year; + final count = maxYear - minYear + 1; + final selectedIndex = _currentMonth.year - minYear; + // 让选中项默认居中(每行约 56 dp,参考 ListTile 默认高度)。 + const itemExtent = 56.0; + final controller = ScrollController( + initialScrollOffset: (selectedIndex * itemExtent - 120).clamp( + 0.0, + (count * itemExtent - 200).clamp(0.0, double.infinity), ), - const SizedBox(height: 16), - // 日历主体 - ValueListenableBuilder( - valueListenable: showLunarInfo, - builder: (context, show, child) { - return ValueListenableBuilder( - valueListenable: selectedDate, - builder: (context, value, child) { - return TCalendar( - title: '', - showLunarInfo: show, - dataSource: dataSource, - value: value, - height: size.height * 0.6, - onChange: (newValue) { - selectedDate.value = newValue; - - // 显示完整农历信息 - final date = DateTime.fromMillisecondsSinceEpoch(newValue[0]); - final lunarInfo = dataSource.getLunarInfo(date); - final solarTerm = dataSource.getSolarTerm(date); - final festival = dataSource.getFestival(date, lunarInfo); - final holidayInfo = dataSource.getHolidayInfo(date); - - final buffer = StringBuffer(); - buffer.write('阳历:${date.year}年${date.month}月${date.day}日'); - - if (lunarInfo != null) { - buffer.write('\n农历:${lunarInfo.monthText}${lunarInfo.dayText}'); - } - - if (solarTerm != null && solarTerm.isNotEmpty) { - buffer.write('\n节气:$solarTerm'); - } - - if (festival != null && festival.isNotEmpty) { - buffer.write('\n节日:$festival'); - } - - if (holidayInfo != null) { - final type = holidayInfo['type'] == 'holiday' ? '假期' : '调休'; - buffer.write('\n$type:${holidayInfo['name']}'); - } - - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(buffer.toString()), - duration: const Duration(seconds: 3), - behavior: SnackBarBehavior.floating, - ), - ); - }, - ); - }, - ); - }, + ); + final year = await showModalBottomSheet( + context: context, + builder: (ctx) { + return SizedBox( + height: 300, + child: Column( + children: [ + const Padding( + padding: EdgeInsets.all(16), + child: Text('选择年份', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ), + Expanded( + child: ListView.builder( + controller: controller, + itemExtent: itemExtent, + itemCount: count, + itemBuilder: (ctx, index) { + final y = minYear + index; + final isSelected = y == _currentMonth.year; + return ListTile( + title: Text('$y年', + style: TextStyle( + color: isSelected ? Colors.blue : null, + fontWeight: + isSelected ? FontWeight.bold : null, + )), + trailing: isSelected + ? const Icon(Icons.check, color: Colors.blue) + : null, + onTap: () => Navigator.pop(ctx, y), + ); + }, + ), + ), + ], + ), + ); + }, + ); + controller.dispose(); + if (year != null) { + // 切年时,目标月可能在端点年越界,_navigateTo 内部会兜底 clamp。 + _navigateTo(DateTime(year, _currentMonth.month, 1)); + } + } + + Future _pickMonth() async { + final minKey = widget.minDate.year * 12 + widget.minDate.month; + final maxKey = widget.maxDate.year * 12 + widget.maxDate.month; + final m = await showModalBottomSheet( + context: context, + builder: (ctx) { + return SizedBox( + height: 400, + child: Column( + children: [ + const Padding( + padding: EdgeInsets.all(16), + child: Text('选择月份', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ), + Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: 12, + itemBuilder: (ctx, index) { + final month = index + 1; + final monthKey = _currentMonth.year * 12 + month; + final isDisabled = + monthKey < minKey || monthKey > maxKey; + final isSelected = + !isDisabled && month == _currentMonth.month; + final bgColor = isDisabled + ? Colors.grey.shade100 + : (isSelected ? Colors.blue : Colors.grey.shade200); + final fgColor = isDisabled + ? Colors.grey.shade400 + : (isSelected ? Colors.white : Colors.black); + return InkWell( + onTap: isDisabled + ? null + : () => Navigator.pop(ctx, month), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8), + ), + child: Text('$month月', + style: TextStyle( + color: fgColor, + fontWeight: + isSelected ? FontWeight.bold : null, + )), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + if (m != null) { + _navigateTo(DateTime(_currentMonth.year, m, 1)); + } + } + + @override + Widget build(BuildContext context) { + final lunarInfo = widget.dataSource.getLunarInfo(_currentMonth); + final lunarMonth = lunarInfo != null + ? '${lunarInfo.yearText}年 ${lunarInfo.monthText}' + : ''; + final canPrev = _canGoPrev(); + final canNext = _canGoNext(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 固定高度容器,防止农历文字有无时布局跳动 + SizedBox( + height: 20, + child: lunarMonth.isNotEmpty + ? Text( + lunarMonth, + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ) + : const SizedBox.shrink(), + ), + Row( + children: [ + TButton( + text: '◀', + size: TButtonSize.small, + theme: TButtonTheme.defaultTheme, + disabled: !canPrev, + onTap: canPrev + ? () => _navigateTo(DateTime( + _currentMonth.year, _currentMonth.month - 1, 1)) + : null, + ), + const SizedBox(width: 4), + Expanded( + child: TButton( + text: '${_currentMonth.year}年', + size: TButtonSize.small, + theme: TButtonTheme.defaultTheme, + onTap: _pickYear, + ), + ), + const SizedBox(width: 4), + Expanded( + child: TButton( + text: '${_currentMonth.month}月', + size: TButtonSize.small, + theme: TButtonTheme.defaultTheme, + onTap: _pickMonth, + ), + ), + const SizedBox(width: 4), + TButton( + text: '▶', + size: TButtonSize.small, + theme: TButtonTheme.defaultTheme, + disabled: !canNext, + onTap: canNext + ? () => _navigateTo(DateTime( + _currentMonth.year, _currentMonth.month + 1, 1)) + : null, + ), + ], + ), + ], ), - ], - ); + ); + } } diff --git a/tdesign-component/example/test/lunar_info_test.dart b/tdesign-component/example/test/lunar_info_test.dart new file mode 100644 index 000000000..8df3f3980 --- /dev/null +++ b/tdesign-component/example/test/lunar_info_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../lib/lunar_info.dart'; + +void main() { + group('LunarInfo (example)', () { + test('creates and formats fullText', () { + const lunarInfo = LunarInfo( + year: 2025, + month: 3, + day: 7, + yearText: '二〇二五', + monthText: '三月', + dayText: '初七', + ); + + expect(lunarInfo.year, 2025); + expect(lunarInfo.fullText, '二〇二五年 三月初七'); + }); + }); +} diff --git a/tdesign-component/lib/src/components/calendar/date_picker_model.dart b/tdesign-component/lib/src/components/calendar/date_picker_model.dart deleted file mode 100644 index c1d5b0c22..000000000 --- a/tdesign-component/lib/src/components/calendar/date_picker_model.dart +++ /dev/null @@ -1,204 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../tdesign_flutter.dart'; -import '../picker/no_wave_behavior.dart'; -import '../picker/t_item_widget.dart'; -import '../picker/t_picker_option.dart'; -import '../picker/t_picker_value.dart'; - -/// 日期选择器数据模型(供 TCalendar 内部时间选择器使用) -/// -/// 精简版,仅包含 TCalendar 时间选择器所需功能 -class DatePickerModel { - final bool useYear; - final bool useMonth; - final bool useDay; - final bool useHour; - final bool useMinute; - final bool useSecond; - final bool useWeekDay; - - /// 可选起始日期 [year, month, day, ...] - final List? dateStart; - - /// 可选结束日期 - final List? dateEnd; - - /// 默认选中的日期 [year, month, day, hour, minute, second, ...] - final List? dateInitial; - - /// 过滤选项 - final List Function(String key, List items)? filterItems; - - DatePickerModel({ - this.useYear = true, - this.useMonth = true, - this.useDay = true, - this.useHour = false, - this.useMinute = false, - this.useSecond = false, - this.useWeekDay = false, - this.dateStart, - this.dateEnd, - this.dateInitial, - this.filterItems, - }); - - /// 获取年数据列表 - List get years { - final start = (dateStart != null && dateStart!.isNotEmpty) ? dateStart![0] : 1900; - final end = (dateEnd != null && dateEnd!.isNotEmpty) ? dateEnd![0] : 2100; - return List.generate(end - start + 1, (i) => start + i); - } - - /// 获取月数据列表 - List get months => List.generate(12, (i) => i + 1); - - /// 获取日数据列表 - List days(int year, int month) { - final daysInMonth = DateTime(year, month + 1).subtract(const Duration(days: 1)).day; - return List.generate(daysInMonth, (i) => i + 1); - } - - /// 获取时数据列表 - List get hours => List.generate(24, (i) => i); - - /// 获取分数据列表 - List get minutes => List.generate(60, (i) => i); - - /// 获取秒数据列表 - List get seconds => List.generate(60, (i) => i); - - /// 获取星期数据列表 - List get weekDays => ['一', '二', '三', '四', '五', '六', '日']; - - /// 所有列的 ScrollController - late List controllers; - late List data; - - /// 命名控制器便捷访问(供 TCalendar 使用) - FixedExtentScrollController get hourFixedExtentScrollController { - int idx = 0; - if (useYear) idx++; - if (useMonth) idx++; - if (useDay) idx++; - return controllers[idx]; - } - - FixedExtentScrollController get minuteFixedExtentScrollController { - int idx = 0; - if (useYear) idx++; - if (useMonth) idx++; - if (useDay) idx++; - if (useHour) idx++; - return controllers[idx]; - } - - FixedExtentScrollController get secondFixedExtentScrollController { - int idx = 0; - if (useYear) idx++; - if (useMonth) idx++; - if (useDay) idx++; - if (useHour) idx++; - if (useMinute) idx++; - return controllers[idx]; - } - - /// 初始化 - void init() { - data = []; - controllers = []; - - if (useYear) data.add(years); - if (useMonth) data.add(months); - if (useDay) data.add([31]); // 占位,下面会刷新 - if (useHour) data.add(hours); - if (useMinute) data.add(minutes); - if (useSecond) data.add(seconds); - if (useWeekDay) data.add(weekDays); - - controllers = List.generate( - data.length, - (_) => FixedExtentScrollController(), - ); - - // 设置初始位置 - if (dateInitial != null) { - final init = dateInitial!; - for (var i = 0; i < init.length && i < controllers.length; i++) { - if (data[i].isNotEmpty) { - final idx = data[i].indexOf(init[i]); - if (idx >= 0) controllers[i].jumpToItem(idx); - } - } - } - - // 刷新日列数据(必须在 controllers 初始化之后,因为需要读取选中的年/月) - if (useDay) _refreshDays(); - } - - /// 根据当前选中值刷新日列数据 - void _refreshDays() { - // 动态计算 day 列的实际索引(前面可能没有 year / month 列) - int dayCol = 0; - if (useYear) dayCol++; - if (useMonth) dayCol++; - if (dayCol >= data.length) return; - - final yearIdx = useYear - ? controllers[0].selectedItem.clamp(0, years.length - 1) - : 0; - final monthCol = useYear ? 1 : 0; - final monthIdx = useMonth - ? controllers[monthCol].selectedItem.clamp(0, months.length - 1) - : 0; - final year = useYear ? years[yearIdx] : DateTime.now().year; - final month = useMonth ? months[monthIdx] : DateTime.now().month; - data[dayCol] = days(year, month); - } - - /// 外部调用:当年/月变化时刷新后续列 - void refreshDataAndController(int changedColumn) { - if (changedColumn == 0 && useMonth) { - // 年变化 → 刷新月 - _refreshDays(); - if (controllers.length > changedColumn + 1) controllers[changedColumn + 1].jumpToItem(0); - } - if (changedColumn == 1 && useDay) { - // 月变化 → 刷新日 - _refreshDays(); - if (controllers.length > changedColumn + 1) controllers[changedColumn + 1].jumpToItem(0); - } - } - - /// 获取当前选中值 - Map get selected { - final result = {}; - var idx = 0; - if (useYear && idx < data.length) { - result['year'] = data[idx][controllers[idx].selectedItem]; - idx++; - } - if (useMonth && idx < data.length) { - result['month'] = data[idx][controllers[idx].selectedItem]; - idx++; - } - if (useDay && idx < data.length) { - result['day'] = data[idx][controllers[idx].selectedItem]; - idx++; - } - if (useHour && idx < data.length) { - result['hour'] = data[idx][controllers[idx].selectedItem]; - idx++; - } - if (useMinute && idx < data.length) { - result['minute'] = data[idx][controllers[idx].selectedItem]; - idx++; - } - if (useSecond && idx < data.length) { - result['second'] = data[idx][controllers[idx].selectedItem]; - idx++; - } - return result; - } -} diff --git a/tdesign-component/lib/src/components/calendar/t_calendar.dart b/tdesign-component/lib/src/components/calendar/t_calendar.dart index 89822da4c..31b7db7fe 100644 --- a/tdesign-component/lib/src/components/calendar/t_calendar.dart +++ b/tdesign-component/lib/src/components/calendar/t_calendar.dart @@ -1,143 +1,211 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../tdesign_flutter.dart'; import '../../util/context_extension.dart'; import '../../util/iterable_ext.dart'; -import 'date_picker_model.dart'; -import 't_date_picker.dart'; - -export 't_calendar_body.dart'; -export 't_calendar_cell.dart'; -export 't_calendar_header.dart'; -export 't_calendar_popup.dart'; -export 't_calendar_style.dart'; +import 't_calendar_body.dart'; +import 't_calendar_cell.dart'; +import 't_calendar_header.dart'; + +export 't_calendar_cell.dart' + show + TCalendarCellModel, + DateSelectTypeNotifier, + DateSelectType, + TCalendarSubtitleContext, + TCalendarSubtitleBuilder, + TCalendarCellBuilder; export 't_calendar_data_source.dart'; -export 't_lunar_date.dart'; +export 't_calendar_style.dart'; + +// --------------------------------------------------------------------------- +// TCalendarInherited — 日历弹窗状态托管,供 TCalendar 内部读取 +// --------------------------------------------------------------------------- + +/// 日历弹窗状态的 InheritedWidget 容器。 +/// +/// 由上层(如 [TSlidePopupRoute] 的 builder)包裹在 [TCalendar] 外侧, +/// 将选中态、确认/关闭回调等注入子树。 +class TCalendarInherited extends InheritedWidget { + const TCalendarInherited({ + required Widget child, + this.onClose, + required this.selected, + this.usePopup = true, + this.popupControls = true, + this.popupConfirmBtn, + this.onConfirm, + this.confirmBtnBuilder, + this.popupOverlayBuilder, + this.popupOverlayExpanded, + Key? key, + }) : assert( + popupOverlayExpanded == null || popupOverlayBuilder != null, + 'popupOverlayExpanded 需配合 popupOverlayBuilder 使用', + ), + super(child: child, key: key); + + final VoidCallback? onClose; + + /// 选中态的可写引用(仅供 [TCalendar] 内部更新使用)。 + /// + /// 对外消费方请使用 [selectedListenable] 这一只读视图。 + final ValueNotifier> selected; + + /// 选中态的只读视图,供下游 widget 监听变化。 + ValueListenable> get selectedListenable => selected; -typedef CalendarFormat = TDate? Function(TDate? day); + final bool? usePopup; -enum CalendarType { single, multiple, range } + /// 是否由 [TCalendar] 自行渲染关闭按钮和标题行。 + /// + /// 为 `true`(默认)时 [TCalendar] 渲染关闭按钮与标题行; + /// 为 `false` 时由外层弹窗容器承载。 + final bool popupControls; -enum CalendarTrigger { closeBtn, confirmBtn, overlay } + /// 是否由 [TCalendar] 渲染底部确认按钮。 + /// + /// 为 `null`(默认)时跟随 [popupControls];显式设置时覆盖。 + final bool? popupConfirmBtn; -enum DateSelectType { selected, disabled, start, centre, end, empty } + /// 实际是否渲染底部确认按钮。 + bool get effectivePopupConfirmBtn => popupConfirmBtn ?? popupControls; + + final VoidCallback? onConfirm; + + /// 自定义确认按钮;[onConfirm] 与默认确认按钮一致(回传选中值并关闭弹窗)。 + final Widget Function(VoidCallback onConfirm)? confirmBtnBuilder; + + /// 弹窗模式下日历内容区底部浮层构建器(非 [TPopup] 面板底部)。 + /// + /// 由 [TCalendar.showPopup] 或手动 [TCalendarInherited] 注入; + /// [selectedDates] 随点选实时更新。 + final Widget Function(BuildContext context, List selectedDates)? + popupOverlayBuilder; + + /// 浮层是否展开(响应式),需配合 [popupOverlayBuilder]。 + final ValueListenable? popupOverlayExpanded; + + /// 仅当 Inherited 上的**静态配置**变化时通知依赖方重建。 + /// + /// [selected] 为 [ValueNotifier],变更走 [selectedListenable],不依赖本方法。 + /// 若返回 `false`,在运行期替换 [popupOverlayBuilder] 等回调时,子树不会自动重建, + /// 弹窗场景一般在 push 时一次性注入,内嵌高级用法请整体替换 Inherited。 + @override + bool updateShouldNotify(covariant TCalendarInherited oldWidget) { + return oldWidget.usePopup != usePopup || + oldWidget.popupControls != popupControls || + oldWidget.popupConfirmBtn != popupConfirmBtn || + oldWidget.onClose != onClose || + oldWidget.onConfirm != onConfirm || + oldWidget.confirmBtnBuilder != confirmBtnBuilder || + oldWidget.popupOverlayBuilder != popupOverlayBuilder || + oldWidget.popupOverlayExpanded != popupOverlayExpanded; + } + + static TCalendarInherited? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } +} + +/// 日历选择模式 +enum CalendarType { + /// 单选:点击新日期时自动取消旧日期的选中状态 + single, + + /// 多选:点击日期切换选中/取消,可同时选中多个日期 + multiple, + + /// 区间选择:第一次点击选起点,第二次点击选终点,中间自动填充 + range, +} /// 日历组件 class TCalendar extends StatefulWidget { const TCalendar({ Key? key, this.firstDayOfWeek = 0, - this.format, this.maxDate, this.minDate, - this.title, this.titleWidget, this.type = CalendarType.single, - this.value, - this.displayFormat = 'year month', - this.cellHeight = 60, + this.initialValue, + this.cellHeight, this.height, - this.width, this.style, this.onChange, this.onCellClick, - this.onCellLongPress, - this.onHeaderClick, - this.useSafeArea = true, - this.useTimePicker = false, - this.timePickerModel, + this.safeAreaInset = true, this.monthTitleHeight = 22, this.monthTitleBuilder, - this.pickerHeight = 178, - this.pickerItemCount = 3, - this.isTimeUnit = true, this.animateTo = false, - this.cellWidget, + this.cellBuilder, + this.subtitleBuilder, this.onMonthChange, this.anchorDate, - this.dateType = TCalendarDateType.solar, + this.anchorRevision = 0, this.dataSource, - this.showLunarInfo = false, }) : super(key: key); - /// 第一天从星期几开始,默认 0 = 周日 - final int? firstDayOfWeek; - - /// 用于格式化日期的函数,可定义日期前后的显示内容和日期样式 - final CalendarFormat? format; + /// 第一天从星期几开始,0 = 周日,1 = 周一,…,6 = 周六。默认 0(周日)。 + final int firstDayOfWeek; - /// 最大可选的日期(fromMillisecondsSinceEpoch),不传则默认半年后 - final int? maxDate; + /// 最大可选的日期,不传则默认 2100-12-31 + final DateTime? maxDate; - /// 最小可选的日期(fromMillisecondsSinceEpoch),不传则默认今天 - final int? minDate; + /// 最小可选的日期,不传则默认 1970-01-01 + final DateTime? minDate; - /// 标题 - final String? title; - - /// 标题组件 + /// 标题组件,可传入 Text 或自定义 Widget final Widget? titleWidget; - /// 日历的选择类型,single = 单选;multiple = 多选;range = 区间选择 - final CalendarType? type; - - /// 当前选择的日期(fromMillisecondsSinceEpoch),不传则默认今天,当 type = single 时数组长度为1 - final List? value; - - /// 年月显示格式,`year`表示年,`month`表示月,如`year month`表示年在前、月在后、中间隔一个空格 - final String? displayFormat; - - /// 高度 + /// 日历的选择模式,决定点击日期后的选中行为: + /// - [CalendarType.single]:单选,点击新日期取消旧选中 + /// - [CalendarType.multiple]:多选,点击切换选中/取消 + /// - [CalendarType.range]:区间选择,依次选起止日期 + final CalendarType type; + + /// 初始选中日期列表,不传则默认今天。 + /// + /// **非受控语义**:仅用于首次挂载;用户点选后以 [onChange] 为准,由调用方自行 + /// `setState` 保存。若父组件在运行期修改本参数,会同步选中态并刷新格子(与 range + /// 行为一致)。 + /// + /// 列表长度与 [type] 对应: + /// - [CalendarType.single]:1 个元素(选中日期) + /// - [CalendarType.multiple]:N 个元素(所有选中日期) + /// - [CalendarType.range]:2 个元素(起始、结束日期) + final List? initialValue; + + /// 高度,不传时内嵌模式自动按 5 行日期计算 final double? height; - /// 日期高度 + /// 日期单元格高度,默认 60。如需更大行高可传入自定义值(如 80) final double? cellHeight; - /// 宽度 - final double? width; - - ///锚点日期 - final DateTime? anchorDate; - /// 自定义样式 final TCalendarStyle? style; /// 选中值变化时触发 - final void Function(List value)? onChange; + final void Function(List value)? onChange; /// 点击日期时触发 final void Function( - int value, - DateSelectType type, - TDate tdate, + DateTime value, + DateSelectType selectType, + TCalendarCellModel cell, )? onCellClick; - /// 长安日期时触发 - final void Function( - int value, - DateSelectType type, - TDate tdate, - )? onCellLongPress; - - /// 点击周时触发 - final void Function( - int index, - String week, - )? onHeaderClick; - /// 月份变化时触发 final ValueChanged? onMonthChange; - /// 是否使用安全区域,默认true - final bool? useSafeArea; - - /// 是否显示时间选择器 - final bool? useTimePicker; - - /// 自定义时间选择器 - final List? timePickerModel; + /// 是否适配底部安全区域(如 iPhone Home Indicator),默认 true + final bool safeAreaInset; - /// 月标题高度 - final double? monthTitleHeight; + /// 每月标题行高度(如 '2025年6月' 所在行),默认 22 + final double monthTitleHeight; /// 月标题构建器 final Widget Function( @@ -145,42 +213,236 @@ class TCalendar extends StatefulWidget { DateTime monthDate, )? monthTitleBuilder; - /// 时间选择器List的视窗高度 - final double? pickerHeight; + /// 滚动到选中日期/锚点日期所在月份时是否使用动画,默认 false + final bool animateTo; - /// 选择器List视窗中item个数,pickerHeight / pickerItemCount即item高度 - final int? pickerItemCount; + /// 整格自定义;设置后不再使用默认主区/副标题布局。 + final TCalendarCellBuilder? cellBuilder; - /// 是否显示时间单位 - final bool? isTimeUnit; + /// 副标题完全自定义;未设置时可使用 [dataSource.getSubtitle]。 + final TCalendarSubtitleBuilder? subtitleBuilder; - /// 动画滚动到指定位置 - final bool? animateTo; - - /// 自定义日期单元格组件 - final Widget? Function( - BuildContext context, - TDate tdate, - DateSelectType selectType, - )? cellWidget; + /// 锚点日期,打开时滚动到该日期所在月份。 + final DateTime? anchorDate; - /// 日历类型:阳历或农历 - final TCalendarDateType dateType; + /// 锚点滚动触发序号,默认 `0`。 + /// + /// 与 [anchorDate] 配合:序号递增可重复滚到同一月份;仅改月份时也可只更新 [anchorDate]。 + final int anchorRevision; - /// 外部数据源,用于提供农历转换等功能 + /// 可选数据源,提供副标题字符串(无 [subtitleBuilder] 时生效)。 final TCalendarDataSource? dataSource; - /// 阳历模式下是否显示农历信息作为副标题 - final bool showLunarInfo; - - List? get _value => value?.map((e) { - final date = DateTime.fromMillisecondsSinceEpoch(e); - return DateTime(date.year, date.month, date.day); + List? get _value => initialValue?.map((e) { + return DateTime(e.year, e.month, e.day); }).toList(); - List? get _valueTime => value?.map((item) { - return DateTime.fromMillisecondsSinceEpoch(item); - }).toList(); + // --------------------------------------------------------------------------- + // 默认高度计算常量 + // --------------------------------------------------------------------------- + static const double _kPanelHeaderHeight = 58.0; + static const double _kWeekdayHeight = 46.0; + static const double _kMonthTitleHeight = 22.0; + static const double _kCellHeight = 60.0; + static const double _kVerticalGap = 8.0; + static const int _kVisibleRows = 5; + static const double _kConfirmBtnAreaHeight = 64.0; + static const double _kBodyPadding = 16.0; + static const double _kPopupHeightRatio = 0.9; + + static double _calcDefaultHeight(double safeBottom, double screenHeight) { + const calendarContentHeight = _kWeekdayHeight + + _kMonthTitleHeight + + _kVisibleRows * (_kVerticalGap + _kCellHeight) + + _kBodyPadding * 2; + final idealHeight = _kPanelHeaderHeight + + calendarContentHeight + + _kConfirmBtnAreaHeight + + safeBottom; + return idealHeight.clamp(0.0, screenHeight * _kPopupHeightRatio); + } + + /// 弹出日历选择器,返回选中的日期列表。 + /// + /// 取消或关闭弹窗时返回 `null`;点击确认时返回选中的 [DateTime] 列表。 + /// 弹窗内点选过程无 [onChange];实时联动请用 [popupOverlayBuilder] 的 `dates`, + /// 或自行用 [TCalendarInherited] 监听 [TCalendarInherited.selectedListenable]。 + /// + /// ```dart + /// final result = await TCalendar.showPopup( + /// context, + /// titleWidget: Text('请选择日期'), + /// type: CalendarType.single, + /// ); + /// if (result != null) { + /// print('选中了: $result'); + /// } + /// ``` + /// + /// 若需完全自定义布局,请直接使用 [TCalendar] + [TPopup.show] + /// / [TPopupOptions.bottom] 自行组装。 + static Future?> showPopup( + BuildContext context, { + /// 弹窗标题组件,由 [TPopupOptions.bottom] 头部承载(不传入内层 [TCalendar])。 + Widget? titleWidget, + + /// 日历选择模式 + CalendarType type = CalendarType.single, + + /// 初始选中日期列表 + List? initialValue, + + /// 最小可选日期 + DateTime? minDate, + + /// 最大可选日期 + DateTime? maxDate, + + /// 锚点日期,弹出时自动滚动到该日期所在月份 + DateTime? anchorDate, + + /// 锚点滚动触发序号,见 [TCalendar.anchorRevision] + int anchorRevision = 0, + + /// 弹窗面板高度(不传时自动计算) + double? popupHeight, + + /// 第一天从星期几开始,0 = 周日,1 = 周一,…,6 = 周六。默认 0(周日)。 + int firstDayOfWeek = 0, + + /// 日期单元格高度 + double? cellHeight, + + /// 自定义样式 + TCalendarStyle? style, + + /// 弹窗模式下日历内容区底部浮层(经 [TCalendarInherited] 注入,仅弹窗内生效)。 + Widget Function(BuildContext context, List selectedDates)? + popupOverlayBuilder, + + /// 浮层是否展开(响应式),需配合 [popupOverlayBuilder]。 + ValueListenable? popupOverlayExpanded, + + /// 自定义确认按钮,[onConfirm] 与默认确认按钮一致。 + Widget Function(VoidCallback onConfirm)? confirmBtnBuilder, + + /// 点击确认按钮时触发 + void Function(List)? onConfirm, + + /// 弹窗关闭后触发(无论确认还是取消) + VoidCallback? onClose, + + /// 点击日期时触发 + void Function( + DateTime value, + DateSelectType selectType, + TCalendarCellModel cell, + )? onCellClick, + + TCalendarCellBuilder? cellBuilder, + TCalendarSubtitleBuilder? subtitleBuilder, + TCalendarDataSource? dataSource, + + /// 月份变化时触发 + ValueChanged? onMonthChange, + + /// 月标题构建器 + Widget Function(BuildContext context, DateTime monthDate)? monthTitleBuilder, + }) async { + final selected = ValueNotifier>(initialValue ?? []); + final completer = Completer?>(); + TPopupHandle? handle; + List? result; + var closed = false; + + void closePopup() { + if (closed) { + return; + } + handle?.close(); + } + + void completeClose() { + if (closed) { + return; + } + closed = true; + onClose?.call(); + if (!completer.isCompleted) { + completer.complete(result); + } + } + + final mediaQuery = MediaQuery.of(context); + final panelHeight = popupHeight ?? + _calcDefaultHeight(mediaQuery.padding.bottom, mediaQuery.size.height); + final calendarHeight = + (panelHeight - _kPanelHeaderHeight).clamp(0.0, double.infinity); + final resolvedStyle = style ?? TCalendarStyle.generateStyle(context); + final popupTitleWidget = wrapCalendarTitleWidget( + titleWidget, + titleStyle: resolvedStyle.titleStyle, + titleMaxLine: resolvedStyle.titleMaxLine, + ); + + handle = TPopup.show( + context, + options: TPopupOptions.bottom( + titleWidget: popupTitleWidget, + cancelBuilder: null, + confirmBuilder: (ctx, close) => IconButton( + icon: Icon( + TIcons.close, + color: style?.titleCloseColor, + ), + onPressed: close, + ), + height: panelHeight, + closeOnOverlayClick: true, + onClosed: completeClose, + child: PopScope( + canPop: true, + child: TCalendarInherited( + selected: selected, + usePopup: true, + popupControls: false, + popupConfirmBtn: true, + confirmBtnBuilder: confirmBtnBuilder, + popupOverlayBuilder: popupOverlayBuilder, + popupOverlayExpanded: popupOverlayExpanded, + onClose: () { + closePopup(); + }, + onConfirm: () { + result = List.from(selected.value); + onConfirm?.call(result!); + closePopup(); + }, + child: TCalendar( + height: calendarHeight, + type: type, + initialValue: initialValue, + minDate: minDate, + maxDate: maxDate, + anchorDate: anchorDate, + anchorRevision: anchorRevision, + firstDayOfWeek: firstDayOfWeek, + cellHeight: cellHeight, + style: style, + onCellClick: onCellClick, + cellBuilder: cellBuilder, + subtitleBuilder: subtitleBuilder, + dataSource: dataSource, + onMonthChange: onMonthChange, + monthTitleBuilder: monthTitleBuilder, + ), + ), + ), + ), + ); + + return completer.future; + } @override _TCalendarState createState() => _TCalendarState(); @@ -189,12 +451,35 @@ class TCalendar extends StatefulWidget { class _TCalendarState extends State { late List weekdayNames; late List monthNames; - late TCalendarInherited? inherited; + TCalendarInherited? inherited; late TCalendarStyle _style; - final List timePickerModelList = []; + + List? _cachedValueDates; + + /// single 模式下当前选中的单元格引用(来自 body 缓存的当前实例)。 + /// + /// cell 不再反查 `_data` 找上一个 selected:state 维护这条权威引用,点击 + /// 时直接 setType(empty) 即可。引用会随 body 缓存重生成(cleanup 后再滚回 + /// 该月)被 [_handleCellGenerated] 覆盖为新实例,不会出现"指向已 detach + /// 的 cell"导致视觉残留。 + TCalendarCellModel? _selectedSingleRef; + + /// multiple 模式下当前所有选中的单元格引用,按日期键。 + final Map _selectedMultipleRefs = {}; + + // bottom 展开时日历主体上移的距离,露出 bottom 顶部"把手"区域。 + static const double _bottomPeekHeight = 30.0; + + static const double _confirmBtnHeight = 48.0; + static const Duration _animDuration = Duration(milliseconds: 200); + static const Curve _animCurve = Curves.easeInOut; + + bool _initializedSelected = false; + @override void didChangeDependencies() { super.didChangeDependencies(); + inherited = TCalendarInherited.of(context); weekdayNames = [ context.resource.sunday, context.resource.monday, @@ -219,216 +504,404 @@ class _TCalendarState extends State { context.resource.december, ]; _style = widget.style ?? TCalendarStyle.generateStyle(context); + if (!_initializedSelected) { + _initializedSelected = true; + _refreshValueCache(); + _syncSelectedToInheritedSync(); + } + } + + @override + void didUpdateWidget(covariant TCalendar oldWidget) { + super.didUpdateWidget(oldWidget); + if (!listEquals(oldWidget.initialValue, widget.initialValue)) { + _refreshValueCache(); + _syncSelectedToInheritedDeferred(); + } + } + + void _refreshValueCache() { + _cachedValueDates = widget._value; + } + + // 仅在非 build phase 调用。 + void _syncSelectedToInheritedSync() { + if (inherited == null) { + return; + } + inherited!.selected.value = _getValue(widget.initialValue ?? const []); + } + + // 适用于 build phase 调用,写操作延迟到下一帧。 + void _syncSelectedToInheritedDeferred() { + if (inherited == null) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || inherited == null) { + return; + } + inherited!.selected.value = _getValue(widget.initialValue ?? const []); + }); } @override Widget build(BuildContext context) { - inherited = TCalendarInherited.of(context); - _initValue(); - timePickerModelList.clear(); final verticalGap = _style.verticalGap ?? TTheme.of(context).spacer8; - return Container( - height: widget.height, - width: widget.width ?? double.infinity, - decoration: _style.decoration, - child: Column( + final popupOverlayBuilder = inherited?.popupOverlayBuilder; + final popupOverlayExpanded = inherited?.popupOverlayExpanded; + final hasBottom = + inherited?.usePopup == true && popupOverlayBuilder != null; + + Widget stackContent(bool bottomExpanded) { + return Stack( + fit: StackFit.expand, children: [ - TCalendarHeader( - firstDayOfWeek: widget.firstDayOfWeek ?? 0, - weekdayGap: TTheme.of(context).spacer4, - padding: TTheme.of(context).spacer16, - weekdayStyle: _style.weekdayStyle, - weekdayHeight: 46, - title: widget.title, - titleStyle: _style.titleStyle, - titleWidget: widget.titleWidget, - titleMaxLine: _style.titleMaxLine, - titleOverflow: TextOverflow.ellipsis, - closeBtn: inherited?.usePopup ?? false, - closeColor: _style.titleCloseColor, - weekdayNames: weekdayNames, - onClose: inherited?.onClose, - onClick: widget.onHeaderClick, - ), - Expanded( - child: TCalendarBody( - type: widget.type ?? CalendarType.single, - firstDayOfWeek: widget.firstDayOfWeek ?? 0, - maxDate: widget.maxDate, - anchorDate: widget.anchorDate, - minDate: widget.minDate, - value: widget._value, - bodyPadding: _style.bodyPadding ?? TTheme.of(context).spacer16, - displayFormat: widget.displayFormat ?? 'year month', - monthNames: monthNames, - monthTitleStyle: _style.monthTitleStyle, - verticalGap: verticalGap, - cellHeight: _getEffectiveCellHeight(), - monthTitleHeight: widget.monthTitleHeight ?? 22, - monthTitleBuilder: widget.monthTitleBuilder, - animateTo: widget.animateTo ?? false, - onMonthChange: widget.onMonthChange, - dateType: widget.dateType, - dataSource: widget.dataSource, - builder: (date, dateList, data, rowIndex, colIndex) { - return TCalendarCell( - height: _getEffectiveCellHeight(), - tdate: date, - format: widget.format, - type: widget.type ?? CalendarType.single, - data: data, - padding: verticalGap / 2, - onChange: (value) { - final time = _getValue(value); - inherited?.selected.value = time; - widget.onChange?.call(time); - }, - onCellClick: widget.onCellClick, - onCellLongPress: widget.onCellLongPress, - dateList: dateList, - rowIndex: rowIndex, - colIndex: colIndex, - cellWidget: widget.cellWidget, - dateType: widget.dateType, - showLunarInfo: widget.showLunarInfo, - ); - }, - ), - ), - if (widget.useTimePicker == true) _getTimePicker(), - if (inherited?.usePopup == true) - inherited?.confirmBtn ?? - Padding( - padding: widget.useSafeArea == true - ? EdgeInsets.only(top: TTheme.of(context).spacer16) - : EdgeInsets.symmetric( - vertical: TTheme.of(context).spacer16), - child: TButton( - theme: TButtonTheme.primary, - text: context.resource.confirm, - isBlock: true, - size: TButtonSize.large, - onTap: inherited?.onConfirm, - ), - ), - if (widget.useSafeArea == true) - SizedBox(height: MediaQuery.of(context).padding.bottom) + _buildMainColumn(verticalGap, hasBottom, bottomExpanded), + if (hasBottom) _buildBottom(bottomExpanded), ], - ), + ); + } + + final child = hasBottom && popupOverlayExpanded != null + ? ValueListenableBuilder( + valueListenable: popupOverlayExpanded, + builder: (context, expanded, _) => stackContent(expanded), + ) + : stackContent(hasBottom); + + return Container( + height: widget.height ?? _calcInlineDefaultHeight(verticalGap), + width: double.infinity, + decoration: _style.decoration, + child: child, ); } - Widget _getTimePicker() { - final noRange = widget.type != CalendarType.range; - final now = DateTime.now(); - final valueTime = widget._valueTime; - return Container( - decoration: BoxDecoration( - color: TTheme.of(context).bgColorContainer, - boxShadow: const [ - BoxShadow( - color: Color.fromRGBO(0, 0, 0, 0.04), - blurRadius: 12, - offset: Offset(0, -2), - ), - ], - ), - child: Row( - children: List.generate( - noRange ? 1 : 2, - (index) { - final timePickerModel = widget.timePickerModel?.getOrNull(index) ?? - DatePickerModel( - useYear: false, - useMonth: false, - useDay: false, - useWeekDay: false, - useHour: true, - useMinute: true, - useSecond: false, - dateStart: [1999, 01, 01], - dateEnd: [2999, 12, 31], - dateInitial: [ - ...[1999, 01, 01], - valueTime?.getOrNull(index)?.hour ?? now.hour, - valueTime?.getOrNull(index)?.minute ?? now.minute, - valueTime?.getOrNull(index)?.second ?? now.second - ], - ); - final timePicker = TDatePicker( - title: noRange - ? context.resource.time - : index == 0 - ? context.resource.start - : context.resource.end, - leftText: '', - rightText: '', - model: timePickerModel, - pickerHeight: widget.pickerHeight ?? 178, - pickerItemCount: widget.pickerItemCount ?? 3, - isTimeUnit: widget.isTimeUnit ?? true, - onConfirm: (selected) {}, - onSelectedItemChanged: (wheelIndex, index) { - final time = _getValue(inherited?.selected.value ?? []); - inherited?.selected.value = time; - widget.onChange?.call(time); - }, - ); - timePickerModelList.add(timePickerModel); - return Expanded(child: timePicker); - }, + /// 当 [TCalendarInherited.popupControls] 为 `true` 时,由 [TCalendar] + /// 自行渲染关闭按钮与标题行;为 `false` 时由外层面板承载。 + bool get _showPopupControls => + (inherited?.usePopup ?? false) && (inherited?.popupControls ?? true); + + /// 是否渲染底部确认按钮,由 [TCalendarInherited.popupConfirmBtn] 控制。 + bool get _showPopupConfirmBtn => + (inherited?.usePopup ?? false) && + (inherited?.effectivePopupConfirmBtn ?? false); + + Widget _buildMainColumn( + double verticalGap, + bool hasBottom, + bool bottomExpanded, + ) { + return Column( + children: [ + TCalendarHeader( + firstDayOfWeek: widget.firstDayOfWeek, + weekdayGap: TTheme.of(context).spacer4, + padding: TTheme.of(context).spacer16, + weekdayStyle: _style.weekdayStyle, + weekdayHeight: 46, + titleWidget: _showPopupControls ? widget.titleWidget : null, + titleStyle: _style.titleStyle, + titleMaxLine: _style.titleMaxLine, + titleOverflow: TextOverflow.ellipsis, + closeBtn: _showPopupControls, + closeColor: _style.titleCloseColor, + weekdayNames: weekdayNames, + onClose: inherited?.onClose, ), + Expanded( + child: _buildBodyArea(verticalGap, hasBottom, bottomExpanded), + ), + if (_showPopupConfirmBtn) _buildConfirmBtnArea(context), + if (widget.safeAreaInset) + SizedBox(height: MediaQuery.of(context).padding.bottom) + ], + ); + } + + Widget _buildConfirmBtnArea(BuildContext context) { + final onConfirm = inherited?.onConfirm; + if (inherited?.confirmBtnBuilder != null) { + return inherited!.confirmBtnBuilder!(onConfirm ?? () {}); + } + return Padding( + padding: widget.safeAreaInset + ? EdgeInsets.only(top: TTheme.of(context).spacer16) + : EdgeInsets.symmetric(vertical: TTheme.of(context).spacer16), + child: TButton( + theme: TButtonTheme.primary, + text: context.resource.confirm, + isBlock: true, + size: TButtonSize.large, + onTap: onConfirm, ), ); } - List _getValue(List value) { - var dateValue = value.map((e) { - final date = DateTime.fromMillisecondsSinceEpoch(e); - return DateTime(date.year, date.month, date.day).millisecondsSinceEpoch; - }).toList(); - if (widget.useTimePicker != true) { - return dateValue; + Widget _buildBodyArea( + double verticalGap, + bool hasBottom, + bool bottomExpanded, + ) { + final body = _buildCalendarBody(verticalGap); + if (!hasBottom) { + return body; } - final milliseconds = timePickerModelList.map((model) { - final hour = model.useHour - ? model.hourFixedExtentScrollController.selectedItem - : 0; - final minute = model.useMinute - ? model.minuteFixedExtentScrollController.selectedItem - : 0; - final second = model.useSecond - ? model.secondFixedExtentScrollController.selectedItem - : 0; - return (hour * 60 * 60 + minute * 60 + second) * 1000; - }).toList(); - if (widget.type == CalendarType.range && dateValue.length == 1) { - dateValue.add(dateValue.first); + if (inherited?.popupOverlayExpanded == null) { + return Padding( + padding: const EdgeInsets.only(bottom: _bottomPeekHeight), + child: body, + ); + } + return AnimatedPadding( + duration: _animDuration, + curve: _animCurve, + padding: EdgeInsets.only( + bottom: bottomExpanded ? _bottomPeekHeight : 0.0, + ), + child: body, + ); + } + + Widget _buildCalendarBody(double verticalGap) { + return TCalendarBody( + type: widget.type, + firstDayOfWeek: widget.firstDayOfWeek, + maxDate: widget.maxDate, + anchorDate: widget.anchorDate, + anchorRevision: widget.anchorRevision, + minDate: widget.minDate, + initialValue: _cachedValueDates, + bodyPadding: _style.bodyPadding ?? TTheme.of(context).spacer16, + monthNames: monthNames, + monthTitleStyle: _style.monthTitleStyle, + verticalGap: verticalGap, + cellHeight: _getEffectiveCellHeight(), + monthTitleHeight: widget.monthTitleHeight, + monthTitleBuilder: widget.monthTitleBuilder, + animateTo: widget.animateTo, + onMonthChange: widget.onMonthChange, + onCellGenerated: _handleCellGenerated, + onCacheInvalidated: _handleCacheInvalidated, + builder: (cell, dateList, rowIndex, colIndex) { + return TCalendarCell( + height: _getEffectiveCellHeight(), + cell: cell, + padding: verticalGap / 2, + onTap: _handleCellTap, + dateList: dateList, + rowIndex: rowIndex, + colIndex: colIndex, + cellBuilder: widget.cellBuilder, + subtitleBuilder: widget.subtitleBuilder, + dataSource: widget.dataSource, + dayStyle: _style.dayStyle, + todayDayStyle: _style.todayDayStyle, + subtitleStyle: _style.subtitleStyle, + ); + }, + ); + } + + /// 月份单元格列表新生成时被 body 调用:登记 selected 引用, + /// 让 state 不依赖 body 内部缓存即可定位当前选中的 cell 实例。 + /// + /// single:每月最多一个 selected,遇到即覆盖 _selectedSingleRef。 + /// multiple:把当月所有 selected 的引用按 date 写入 map。 + /// range:本身走 initialValue 重建路径,不需要登记。 + void _handleCellGenerated(DateTime monthDate, List cells) { + if (widget.type == CalendarType.range) { + return; } - return dateValue.mapWidthIndex((e, index) { - if (widget.type != CalendarType.range) { - return e + (milliseconds.getOrNull(0) ?? 0); + for (final cell in cells) { + if (cell == null) { + continue; } - return e + (milliseconds.getOrNull(index) ?? 0); - }).toList(); + if (cell.typeNotifier.value != DateSelectType.selected) { + continue; + } + if (widget.type == CalendarType.single) { + _selectedSingleRef = cell; + } else if (widget.type == CalendarType.multiple) { + _selectedMultipleRefs[cell.date] = cell; + } + } } - void _initValue() { - if (inherited == null) { + /// 当 body 整体清空缓存时(minDate/maxDate 变化等),同步清空选中映射, + /// 避免悬挂指向已被替换的 cell 实例。后续月份重新生成时会再次登记。 + void _handleCacheInvalidated() { + _selectedSingleRef = null; + _selectedMultipleRefs.clear(); + } + + /// 三种模式统一入口:cell 仅上抛被点击的模型,由本方法做所有决策。 + /// + /// 行为约定: + /// - disabled:仅触发 onCellClick,不改变选中态 + /// - single:切换 _selectedSingleRef,旧引用置 empty、新引用置 selected + /// - multiple:根据 _selectedMultipleRefs 切换该日期的选中态 + /// - range:交由 [_resolveRangeSelection] 决策后走 setState 重建(保持原有路径) + void _handleCellTap(TCalendarCellModel cell) { + final selectType = cell.typeNotifier.value; + final curDate = cell.date; + + if (selectType == DateSelectType.disabled) { + widget.onCellClick?.call(curDate, selectType, cell); return; } - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - inherited!.selected.value = _getValue(widget.value ?? []); - }); + + switch (widget.type) { + case CalendarType.single: + if (identical(_selectedSingleRef, cell)) { + widget.onCellClick?.call(curDate, cell.typeNotifier.value, cell); + return; + } + _selectedSingleRef?.typeNotifier.setType(DateSelectType.empty); + cell.typeNotifier.setType(DateSelectType.selected); + _selectedSingleRef = cell; + _emitSelection([curDate], rebuild: false); + widget.onCellClick?.call(curDate, cell.typeNotifier.value, cell); + break; + case CalendarType.multiple: + final existing = _selectedMultipleRefs[curDate]; + List nextValue; + if (existing != null) { + existing.typeNotifier.setType(DateSelectType.empty); + _selectedMultipleRefs.remove(curDate); + } else { + cell.typeNotifier.setType(DateSelectType.selected); + _selectedMultipleRefs[curDate] = cell; + } + nextValue = _selectedMultipleRefs.keys.toList()..sort(); + _emitSelection(nextValue, rebuild: false); + widget.onCellClick?.call(curDate, cell.typeNotifier.value, cell); + break; + case CalendarType.range: + final resolved = _resolveRangeSelection([curDate]); + _emitSelection(resolved, rebuild: true); + final reportedType = resolved.length >= 2 && resolved[1] == curDate + ? DateSelectType.end + : DateSelectType.start; + widget.onCellClick?.call(curDate, reportedType, cell); + break; + } + } + + /// 统一更新 _cachedValueDates / inherited.selected / onChange,并按需触发 setState。 + void _emitSelection(List value, {required bool rebuild}) { + _cachedValueDates = value; + inherited?.selected.value = value; + widget.onChange?.call(value); + if (rebuild && mounted) { + setState(() {}); + } + } + + /// range 模式专用的选区决策: + /// - 无 start:作为新 start + /// - 已有 start 且无 end,且点击晚于 start:作为 end,区间完成 + /// - 其它(已完成区间 / 点击早于等于 start):以本次点击重新开始 + List _resolveRangeSelection(List rawValue) { + if (rawValue.isEmpty) { + return const []; + } + final tapped = DateTime( + rawValue.first.year, + rawValue.first.month, + rawValue.first.day, + ); + final current = _cachedValueDates ?? const []; + final hasStart = current.isNotEmpty; + final hasEnd = current.length >= 2; + if (hasStart && !hasEnd && tapped.isAfter(current[0])) { + return [current[0], tapped]; + } + return [tapped]; + } + + // 行为约定详见 [TCalendarInherited.popupOverlayBuilder]。 + Widget _buildBottom(bool bottomExpanded) { + final popupOverlayBuilder = inherited!.popupOverlayBuilder!; + final bottomOffset = _calcBottomOffset(); + + final content = ValueListenableBuilder>( + valueListenable: inherited!.selected, + builder: (context, selectedDates, _) { + return popupOverlayBuilder( + context, + List.unmodifiable(selectedDates), + ); + }, + ); + + if (inherited!.popupOverlayExpanded != null) { + return Positioned( + left: 0, + right: 0, + bottom: bottomOffset, + child: ClipRect( + child: AnimatedSlide( + duration: _animDuration, + curve: _animCurve, + offset: bottomExpanded ? Offset.zero : const Offset(0, 1), + child: content, + ), + ), + ); + } + + return Positioned( + left: 0, + right: 0, + bottom: bottomOffset, + child: content, + ); + } + + // 该值需与 build() 中 Column 底部区域(confirmBtn padding + safeArea)保持一致; + // 修改底部布局时需同步更新本方法。 + double _calcBottomOffset() { + final safeBottom = widget.safeAreaInset + ? MediaQuery.of(context).padding.bottom + : 0.0; + + if (_showPopupConfirmBtn) { + final btnPadding = widget.safeAreaInset + ? TTheme.of(context).spacer16 + : TTheme.of(context).spacer16 * 2; + // 默认与自定义确认按钮均预留固定高度,避免 popupOverlayBuilder 浮层重叠。 + // 若自定义按钮更高,请在 popupHeight 中额外预留空间。 + return safeBottom + btnPadding + _confirmBtnHeight; + } + + return safeBottom; + } + + List _getValue(List value) { + return value.map((e) => DateTime(e.year, e.month, e.day)).toList(); } - /// 获取有效的单元格高度 - /// 当显示农历信息时,需要更大的高度以容纳额外的文本 double _getEffectiveCellHeight() { if (widget.cellHeight != null) { return widget.cellHeight!; } - // 显示农历信息时使用更大的默认高度(80px 完全避免溢出) - return widget.showLunarInfo ? 80 : 60; + return 60; + } + + /// 内嵌模式下不传 `height` 时的默认高度。 + /// + /// 布局 = weekday(46) + monthTitle(22) + 5行(cellHeight + verticalGap) + bodyPadding*2 + double _calcInlineDefaultHeight(double verticalGap) { + const weekdayHeight = 46.0; + final monthTitleHeight = widget.monthTitleHeight; + final cellHeight = _getEffectiveCellHeight(); + final bodyPadding = _style.bodyPadding ?? TTheme.of(context).spacer16; + const visibleRows = 5; + return weekdayHeight + + monthTitleHeight + + visibleRows * (cellHeight + verticalGap) + + bodyPadding * 2; } } diff --git a/tdesign-component/lib/src/components/calendar/t_calendar_body.dart b/tdesign-component/lib/src/components/calendar/t_calendar_body.dart index bcbd4697b..03fedb5ef 100644 --- a/tdesign-component/lib/src/components/calendar/t_calendar_body.dart +++ b/tdesign-component/lib/src/components/calendar/t_calendar_body.dart @@ -1,22 +1,19 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; import '../../../tdesign_flutter.dart'; import '../../util/context_extension.dart'; import '../../util/iterable_ext.dart'; +import 't_calendar_cell.dart'; -class TCalendarBody extends StatelessWidget { +class TCalendarBody extends StatefulWidget { const TCalendarBody({ Key? key, this.maxDate, this.minDate, required this.type, - this.value, + this.initialValue, required this.firstDayOfWeek, required this.builder, required this.bodyPadding, - required this.displayFormat, required this.monthNames, this.monthTitleStyle, this.monthTitleBuilder, @@ -26,25 +23,24 @@ class TCalendarBody extends StatelessWidget { required this.animateTo, this.onMonthChange, this.anchorDate, - this.dateType = TCalendarDateType.solar, - this.dataSource, + this.anchorRevision = 0, + this.onCellGenerated, + this.onCacheInvalidated, }) : super(key: key); - final int? maxDate; - final int? minDate; + final DateTime? maxDate; + final DateTime? minDate; final CalendarType type; - final List? value; + final List? initialValue; final DateTime? anchorDate; final int firstDayOfWeek; final Widget Function( - TDate? date, - List dateList, - Map> data, + TCalendarCellModel? cell, + List dateList, int rowIndex, int colIndex, ) builder; final double bodyPadding; - final String displayFormat; final List monthNames; final TextStyle? monthTitleStyle; final Widget Function( @@ -56,90 +52,306 @@ class TCalendarBody extends StatelessWidget { final double cellHeight; final bool animateTo; final ValueChanged? onMonthChange; - final TCalendarDateType dateType; - final TCalendarDataSource? dataSource; + + /// 锚点滚动触发序号。与 [anchorDate] 配合:序号变化或锚点目标月份变化时滚动。 + /// + /// 重复导航到同一月份时递增即可,无需每次 `new DateTime`。 + final int anchorRevision; + + /// 在每个月份的单元格列表新生成时回调,便于上层登记选中引用。 + final void Function(DateTime monthDate, List cells)? + onCellGenerated; + + /// 当 `_data` 整体被清空时回调,上层清空选中映射。 + final VoidCallback? onCacheInvalidated; @override - Widget build(BuildContext context) { - final scrollController = TrackingScrollController(); - final min = _getDefDate(minDate); - final max = _getDefDate(maxDate, 6); - final months = _monthsBetween(min, max); - final data = >{}; - final monthHeight = {}; - DateTime? _lastPrintMonth; - scrollController.addListener(() { - // 根据滚动位置判断当前是几月 - var currentOffset = 0.0; - for (var i = 0; i < months.length; i++) { - final mh = _getMonthHeight(months, i, monthHeight); - if (scrollController.offset >= currentOffset && - scrollController.offset < currentOffset + mh) { - //只返回下一个月 - DateTime currentMonth = months[i + 1]; - // 缓存上一次打印的月份,只有变更时才打印 - if (_lastPrintMonth == null || - !_lastPrintMonth!.isAtSameMomentAs(currentMonth)) { - _lastPrintMonth = currentMonth; - onMonthChange?.call(currentMonth); - } - break; - } - currentOffset += mh; + State createState() => _TCalendarBodyState(); +} + +class _TCalendarBodyState extends State { + late final ScrollController _scrollController; + int? _lastNotifiedMonthKey; + final _data = >{}; + final _monthHeight = {}; + late List _months; + late DateTime _min; + late DateTime _max; + + /// 月份累计高度的前缀和:`_prefixHeights[i]` = 第 0..i-1 月的高度之和。 + /// 长度为 `_months.length + 1`,用于 O(log n) 二分查找可见月份。 + late List _prefixHeights; + + /// 程序化滚动期间静默 onMonthChange 回调,避免高频中间值打扰外部。 + /// + /// 生命周期: + /// - 由 `_smoothScrollTo` 在滚动开始时设为 true; + /// - 由 `_runAnimateTo.whenComplete` 在动画完成或被打断时复位 false; + /// - `addPostFrameCallback` 中检测到 controller 已分离时也会兜底复位, + /// 避免外部永久收不到 onMonthChange 回调。 + bool _programmaticScroll = false; + + @override + void initState() { + super.initState(); + _initMonths(); + final initialOffset = _calcScrollOffset(); + _scrollController = ScrollController(initialScrollOffset: initialOffset); + _scrollController.addListener(_onScroll); + // 首屏预热:在第一帧之前生成初始可见月份的数据,避免 itemBuilder + // 第一次 build 时缓存为空,回退路径产生不必要的重算。 + _warmupCacheAround(_indexAtOffset(initialOffset)); + } + + @override + void didUpdateWidget(covariant TCalendarBody oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.minDate != widget.minDate || + oldWidget.maxDate != widget.maxDate) { + _monthHeight.clear(); + _data.clear(); + widget.onCacheInvalidated?.call(); + _lastNotifiedMonthKey = null; + _initMonths(); + } else if (!_listEqualsDate( + oldWidget.initialValue, widget.initialValue)) { + // 选中值由上层更新(TCalendar.initialValue / _cachedValueDates)时清空月份缓存, + // 让所有 cell 基于新 initialValue 重建 typeNotifier,避免 single/multiple/range 残留旧态。 + _data.clear(); + widget.onCacheInvalidated?.call(); + } + if (_shouldScrollToAnchor(oldWidget)) { + _scrollToItem(); + } + } + + bool _shouldScrollToAnchor(TCalendarBody oldWidget) { + final anchor = widget.anchorDate; + if (anchor == null) { + return false; + } + if (widget.anchorRevision != oldWidget.anchorRevision) { + return true; + } + final oldAnchor = oldWidget.anchorDate; + if (oldAnchor == null) { + return true; + } + return anchor.year != oldAnchor.year || anchor.month != oldAnchor.month; + } + + static bool _listEqualsDate(List? a, List? b) { + if (identical(a, b)) { + return true; + } + if (a == null || b == null) { + return false; + } + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + return false; } + } + return true; + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _initMonths() { + _min = _getDefDate(widget.minDate); + _max = _getDefDate(widget.maxDate, true); + _months = _monthsBetween(_min, _max); + _rebuildIndex(); + } + + /// 重建月份前缀和。 + /// + /// 性能考量: + /// - 此前 `_calcScrollOffset` / `_onScroll` 都是 O(n) 线性扫描, + /// `_months.length` 默认 ~1572 时滚动期间累计开销显著。 + /// - 改为预计算前缀和 + 二分查找后,单次查询 O(log n), + /// 滚动监听不再因列表长度而劣化。 + /// + /// 月份索引不再使用反查 Map:因为 `_months` 按月份单调递增, + /// 给定 monthKey 只需 `monthKey - firstKey` 即可 O(1) 算出索引。 + void _rebuildIndex() { + _prefixHeights = List.filled(_months.length + 1, 0.0); + var acc = 0.0; + for (var i = 0; i < _months.length; i++) { + _prefixHeights[i] = acc; + acc += _getMonthHeight(_months, i, _monthHeight); + } + _prefixHeights[_months.length] = acc; + } + + static int _monthKey(DateTime d) => d.year * 12 + d.month; + + /// 二分查找:给定滚动偏移量,返回当前可见的月份索引。 + int _indexAtOffset(double offset) { + if (_months.isEmpty) { + return 0; + } + var lo = 0; + var hi = _months.length - 1; + while (lo < hi) { + final mid = (lo + hi + 1) >> 1; + if (_prefixHeights[mid] <= offset) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo; + } + + /// 计算目标日期所在月份的滚动偏移量 + /// + /// 越界处理策略: + /// - 早于 minDate → clamp 到第一个月 + /// - 晚于 maxDate → clamp 到最后一个月 + /// 这样上层即使传入越界 anchorDate,也不会出现"静默回到顶部"的异常表现。 + double _calcScrollOffset() { + var scrollToDate = widget.anchorDate; + if (scrollToDate == null) { + if (widget.initialValue == null || widget.initialValue!.isEmpty) { + return 0.0; + } + scrollToDate = widget.initialValue!.reduce((a, b) => a.isBefore(b) ? a : b); + } + if (_months.isEmpty) { + return 0.0; + } + // 用 (年*12 + 月) 作为可比较的标量,规避日级别比较带来的边界陷阱。 + final firstKey = _monthKey(_months.first); + final lastKey = _monthKey(_months.last); + final targetKey = _monthKey(scrollToDate); + final clampedKey = targetKey.clamp(firstKey, lastKey); + // O(1) 算术:月份 key 是单调递增的整数,索引 = clampedKey - firstKey。 + final idx = clampedKey - firstKey; + return _prefixHeights[idx]; + } + + void _onScroll() { + if (_months.isEmpty) { + return; + } + final i = _indexAtOffset(_scrollController.offset); + final currentMonth = _months[i]; + final currentKey = _monthKey(currentMonth); + // 只在月份真正变化时回调,且程序化滚动期间静默,避免动画中间值打扰外部。 + if (_lastNotifiedMonthKey != currentKey) { + _lastNotifiedMonthKey = currentKey; + if (!_programmaticScroll) { + widget.onMonthChange?.call(currentMonth); + } + } + _warmupCacheAround(i); + _cleanupCache(i); + } + + /// 预热当前可见月份及其前后相邻月份的缓存。 + /// + /// 把"写入 _data"这一副作用从 itemBuilder 中分离出来,避免 build 阶段写状态。 + /// 范围 ±2 月(共 5 个月)足以覆盖单屏可见与上下少量预渲染月份,超出部分 + /// 由 itemBuilder 走 fallback 直接计算(仍不写缓存)。 + void _warmupCacheAround(int currentIndex) { + if (_months.isEmpty) { + return; + } + const radius = 2; + final lo = (currentIndex - radius).clamp(0, _months.length - 1); + final hi = (currentIndex + radius).clamp(0, _months.length - 1); + for (var i = lo; i <= hi; i++) { + final monthDate = _months[i]; + if (!_data.containsKey(monthDate)) { + final tdates = _getDaysInMonth(monthDate, _min, _max); + _data[monthDate] = tdates; + widget.onCellGenerated?.call(monthDate, tdates); + } + } + } + + /// 清理距离当前可见月份过远的缓存数据,避免在 itemBuilder 中执行副作用。 + /// + /// `_data` 实际只会缓存可见月份附近的少量项(受本方法 ±10 范围限制, + /// 上限约 21 项),遍历开销可忽略,无需额外节流。 + void _cleanupCache(int currentIndex) { + if (_months.isEmpty) { + return; + } + final firstKey = _monthKey(_months.first); + _data.removeWhere((key, _) { + // 用月份 key 算术替代 List.indexOf 的 O(n) 扫描。 + final monthIdx = _monthKey(key) - firstKey; + return monthIdx < currentIndex - 10 || monthIdx > currentIndex + 10; }); - _scrollToItem(scrollController, months, monthHeight); + } + + @override + Widget build(BuildContext context) { return ListView.builder( - padding: EdgeInsets.all(bodyPadding), - controller: scrollController, - itemCount: months.length, + padding: EdgeInsets.all(widget.bodyPadding), + controller: _scrollController, + itemCount: _months.length, itemExtentBuilder: (index, dimensions) => - _getMonthHeight(months, index, monthHeight), + _getMonthHeight(_months, index, _monthHeight), itemBuilder: (context, index) { - final monthDate = months[index]; + final monthDate = _months[index]; final monthYear = monthDate.year.toString() + context.resource.year; - final monthMonth = monthNames[monthDate.month - 1]; - final monthDateText = displayFormat - .replaceFirst('year', monthYear) - .replaceFirst('month', monthMonth); - late List monthData; - if (data.containsKey(monthDate)) { - monthData = data[monthDate]!; + final monthMonth = widget.monthNames[monthDate.month - 1]; + final monthDateText = '$monthYear $monthMonth'; + // 只读:build 不写状态。命中缓存直接用,未命中走纯函数计算并安排 + // 在下一帧补写缓存(postFrameCallback),避免 build 阶段副作用。 + List monthData; + final cached = _data[monthDate]; + if (cached != null) { + monthData = cached; } else { - monthData = data[monthDate] = _getDaysInMonth(monthDate, min, max); + monthData = _getDaysInMonth(monthDate, _min, _max); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + // 仅当下一帧仍未被其他路径填充时写入,幂等。 + // 注册回调也只在真正写入这条新数据时触发,避免重复登记。 + if (!_data.containsKey(monthDate)) { + _data[monthDate] = monthData; + widget.onCellGenerated?.call(monthDate, monthData); + } + }); } - final keyList = [...data.keys]; - final currentIndex = keyList.indexOf(monthDate); - keyList.forEachWidthIndex((key, index) { - if (index < currentIndex - 10 || index > currentIndex + 10) { - // 保留最近 10 个月的数据,防止内存泄露 - data.remove(key); - } - }); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - height: monthTitleHeight, - child: monthTitleBuilder?.call(context, monthDate) ?? - TText(monthDateText, style: monthTitleStyle), + height: widget.monthTitleHeight, + child: widget.monthTitleBuilder?.call(context, monthDate) ?? + TText(monthDateText, style: widget.monthTitleStyle), ), ...List.generate( (monthData.length / 7).ceil(), (rowIndex) => [ - SizedBox(height: verticalGap), + SizedBox(height: widget.verticalGap), Row( children: [ for (int colIndex = 0; colIndex < 7; colIndex++) ...[ - if (colIndex != 0) SizedBox(width: verticalGap / 2), + if (colIndex != 0) + SizedBox(width: widget.verticalGap / 2), Expanded( - child: builder( + child: widget.builder( (rowIndex * 7 + colIndex < monthData.length) ? monthData[rowIndex * 7 + colIndex] : null, monthData, - data, rowIndex, colIndex, ), @@ -149,60 +361,135 @@ class TCalendarBody extends StatelessWidget { ), ], ).expand((element) => element).toList(), - SizedBox(height: index == months.length - 1 ? 0 : bodyPadding), + SizedBox( + height: index == _months.length - 1 ? 0 : widget.bodyPadding), ], ); }, ); } - void _scrollToItem(ScrollController scrollController, List months, - Map monthHeight) { - DateTime? scrollToDate = anchorDate; - if (scrollToDate == null) { - if (value == null || value!.isEmpty) { + void _scrollToItem() { + final height = _calcScrollOffset(); + // 等待 ScrollController 完成 attach 后再滚动,最多重试若干帧。 + void attemptScroll([int retry = 0]) { + if (!mounted) { return; } - scrollToDate = value!.reduce((a, b) => a.isBefore(b) ? a : b); - } - var lastMonthDay = DateTime(months.last.year, months.last.month + 1); - lastMonthDay = lastMonthDay.add(const Duration(days: -1)); - if (months.first.isAfter(scrollToDate) || lastMonthDay.isBefore(scrollToDate)) { - return; + if (!_scrollController.hasClients) { + if (retry >= 5) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + attemptScroll(retry + 1); + }); + return; + } + final position = _scrollController.position; + final clamped = height.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if (widget.animateTo) { + _smoothScrollTo(position, clamped); + } else { + _scrollController.jumpTo(clamped); + } } + WidgetsBinding.instance.addPostFrameCallback((_) { - var height = 0.0; - for (var i = 0; i < months.length; i++) { - final item = months[i]; - if (item.year == scrollToDate!.year && item.month == scrollToDate!.month) { - break; + attemptScroll(); + }); + } + + /// 长距离滚动优化:先 jumpTo 到目标附近,再做一小段动画补尾。 + /// + /// 背景:`ListView.builder` 在跨度极大(如跨年)时一次性高速滚动会引发: + /// 1) itemBuilder 短时间内被调用数十次,每月还要计算农历/节气,主线程被打满 + /// 2) `_onScroll` 沿途反复触发,进一步加重负载 + /// 视觉表现就是"卡顿/掉帧"。 + /// + /// 优化策略(参考 iOS 通讯录 / 微信会话列表的"远距离跳转"行为): + /// - 跨度 ≤ 3 屏:保持原有 200ms 平滑动画,体验无变化 + /// - 跨度 > 3 屏:先 jumpTo 到距离目标 1 屏的位置(瞬时,无 build 压力), + /// 再用 180ms 平滑滚完最后这 1 屏,仍保留"滑过去"的视觉过渡 + /// + /// 同时把整段滚动标记为「程序化滚动」:期间 `_onScroll` 不向外回调 + /// onMonthChange,由调用方负责设置目标月份显示,避免中间月份打扰外部状态。 + void _smoothScrollTo(ScrollPosition position, double target) { + final delta = (target - position.pixels).abs(); + final viewport = position.viewportDimension; + // 阈值:超过 3 个屏幕高度就走 jump + animate 的组合方案 + const thresholdInViewports = 3.0; + + _programmaticScroll = true; + + if (viewport > 0 && delta > viewport * thresholdInViewports) { + // 朝目标方向跳到距离目标 1 屏的位置,给最后一段留出动画空间 + final preJump = target > position.pixels + ? target - viewport + : target + viewport; + _scrollController.jumpTo(preJump.clamp( + position.minScrollExtent, + position.maxScrollExtent, + )); + // 关键:jumpTo 后必须等 ListView 完成一次 layout(itemExtentBuilder 重新算 + // viewport,可见 item 重新生成),否则紧接着 animateTo 可能拿到陈旧的 + // maxScrollExtent / pixels,触发断言或滚到错误位置。 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) { + _programmaticScroll = false; + return; } - height += (_getMonthHeight(months, i, monthHeight) ?? 0); - } - if (height <= 0) { + // 重新 clamp 一次:jumpTo 后 maxScrollExtent 可能因 itemExtentBuilder + // 重算而变化(虽然我们用的是固定高度算法,但稳妥起见仍兜底)。 + final pos = _scrollController.position; + final clampedTarget = target.clamp( + pos.minScrollExtent, + pos.maxScrollExtent, + ); + _runAnimateTo( + clampedTarget, + const Duration(milliseconds: 180), + Curves.easeOut, + ); + }); + } else { + _runAnimateTo( + target, + const Duration(milliseconds: 200), + Curves.easeInOut, + ); + } + } + + /// 统一的 animateTo 包装:处理动画完成 / 中断时的状态恢复。 + void _runAnimateTo(double target, Duration duration, Curve curve) { + if (!_scrollController.hasClients) { + _programmaticScroll = false; + return; + } + _scrollController + .animateTo(target, duration: duration, curve: curve) + .whenComplete(() { + // State 已 dispose 时直接退出,不再触碰 _scrollController(已 dispose)。 + if (!mounted) { return; } - if (animateTo) { - scrollController.animateTo( - height, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ); - } else { - scrollController.jumpTo(height); + _programmaticScroll = false; + // 落定后补发一次 onMonthChange,让外部确认最终月份; + // _onScroll 内部仅在 monthKey 变化时回调,幂等。 + if (_scrollController.hasClients) { + _onScroll(); } }); } - DateTime _getDefDate(int? date, [int? addMonth]) { - final now = date == null - ? DateTime.now() - : DateTime.fromMillisecondsSinceEpoch(date); - if (addMonth == null) { - return DateTime(now.year, now.month, now.day); + DateTime _getDefDate(DateTime? date, [bool isMax = false]) { + if (date != null) { + return DateTime(date.year, date.month, date.day); } - final month = now.month + addMonth; - return DateTime(now.year, date == null ? month : now.month, now.day); + return isMax ? DateTime(2100, 12, 31) : DateTime(1970); } List _monthsBetween(DateTime min, DateTime max) { @@ -215,9 +502,10 @@ class TCalendarBody extends StatelessWidget { return months; } - List _getDaysInMonth(DateTime curDate, DateTime min, DateTime max) { - final daysInMonth = - List.generate(_getPreOffset(curDate), (index) => null); + List _getDaysInMonth( + DateTime curDate, DateTime min, DateTime max) { + final daysInMonth = List.generate( + _getPreOffset(curDate), (index) => null); final daysInMonthCount = DateTime(curDate.year, curDate.month + 1, 0) .day; // 获取下个月的第一天的前一天,即当前月的最后一天 for (var day = 1; day <= daysInMonthCount; day++) { @@ -225,52 +513,44 @@ class TCalendarBody extends StatelessWidget { var selectType = DateSelectType.empty; if (date.compareTo(min) == -1 || date.compareTo(max) == 1) { selectType = DateSelectType.disabled; - } else if (type == CalendarType.single && (value?.length ?? 0) >= 1) { - if (date.compareTo(value![0]) == 0) { + } else if (widget.type == CalendarType.single && + (widget.initialValue?.length ?? 0) >= 1) { + if (date.compareTo(widget.initialValue![0]) == 0) { selectType = DateSelectType.selected; } - } else if (type == CalendarType.multiple && value != null) { - if (value!.isContains((e) => date.compareTo(e) == 0)) { + } else if (widget.type == CalendarType.multiple && + widget.initialValue != null) { + if (widget.initialValue!.isContains((e) => date.compareTo(e) == 0)) { selectType = DateSelectType.selected; } - } else if (type == CalendarType.range && (value?.length ?? 0) >= 1) { - final end = (value?.length ?? 0) > 1 ? value![1] : null; - if (date.compareTo(value![0]) == 0) { + } else if (widget.type == CalendarType.range && + (widget.initialValue?.length ?? 0) >= 1) { + final end = + (widget.initialValue?.length ?? 0) > 1 ? widget.initialValue![1] : null; + if (date.compareTo(widget.initialValue![0]) == 0) { selectType = DateSelectType.start; } - if (end != null && value![0].compareTo(end) < 0) { + if (end != null && widget.initialValue![0].compareTo(end) < 0) { if (date.compareTo(end) == 0) { selectType = DateSelectType.end; } - if (date.compareTo(value![0]) == 1 && date.compareTo(end) == -1) { + if (date.compareTo(widget.initialValue![0]) == 1 && + date.compareTo(end) == -1) { selectType = DateSelectType.centre; } } } - // 获取农历信息 - TLunarInfo? lunarInfo; - String? solarTerm; - String? festival; - Map? holidayInfo; - if (dataSource != null) { - lunarInfo = dataSource!.getLunarInfo(date); - solarTerm = dataSource!.getSolarTerm(date); - festival = dataSource!.getFestival(date, lunarInfo); - holidayInfo = dataSource!.getHolidayInfo(date); - } - daysInMonth.add(TDate( + daysInMonth.add(TCalendarCellModel( date: date, typeNotifier: DateSelectTypeNotifier(selectType), isLastDayOfMonth: daysInMonthCount == day, - lunarInfo: lunarInfo, - solarTerm: solarTerm, - festival: festival, - holidayInfo: holidayInfo, )); } var sufOffset = 7 - daysInMonth.length % 7; sufOffset = sufOffset == 7 ? 0 : sufOffset; - List.generate(sufOffset, (index) => daysInMonth.add(null)); + for (var i = 0; i < sufOffset; i++) { + daysInMonth.add(null); + } return daysInMonth; } @@ -279,7 +559,7 @@ class TCalendarBody extends StatelessWidget { final month = date.month; var dayOneWeek = DateTime(year, month).weekday; dayOneWeek = dayOneWeek == 7 ? 0 : dayOneWeek; - var preOffset = dayOneWeek - firstDayOfWeek; + var preOffset = dayOneWeek - widget.firstDayOfWeek; preOffset = preOffset < 0 ? preOffset + 7 : preOffset; return preOffset; } @@ -298,9 +578,9 @@ class TCalendarBody extends StatelessWidget { final preOffset = _getPreOffset(item); final daysInMonthCount = DateTime(item.year, item.month + 1, 0).day; final daysInMonth = preOffset + daysInMonthCount; - final height = monthTitleHeight + - (daysInMonth / 7).ceil() * (verticalGap + cellHeight) + - (isLast ? 0 : bodyPadding); + final height = widget.monthTitleHeight + + (daysInMonth / 7).ceil() * (widget.verticalGap + widget.cellHeight) + + (isLast ? 0 : widget.bodyPadding); monthHeight[index] = height; return height; } diff --git a/tdesign-component/lib/src/components/calendar/t_calendar_cell.dart b/tdesign-component/lib/src/components/calendar/t_calendar_cell.dart index 1a4efd91e..8b3cd5558 100644 --- a/tdesign-component/lib/src/components/calendar/t_calendar_cell.dart +++ b/tdesign-component/lib/src/components/calendar/t_calendar_cell.dart @@ -1,110 +1,153 @@ import 'package:flutter/material.dart'; import '../../../tdesign_flutter.dart'; import '../../util/iterable_ext.dart'; -import '../../util/list_ext.dart'; +import 't_calendar_data_source.dart'; +import 't_calendar_style.dart'; + +/// 日期在日历格中的选中/展示状态 +enum DateSelectType { selected, disabled, start, centre, end, empty } + +/// 副标题构建上下文 +class TCalendarSubtitleContext { + const TCalendarSubtitleContext({ + required this.date, + required this.selectType, + }); + + final DateTime date; + final DateSelectType selectType; +} + +/// 副标题完全自定义 +typedef TCalendarSubtitleBuilder = Widget? Function( + BuildContext context, + TCalendarSubtitleContext subtitleContext, +); + +/// 整格自定义(主区 + 副标题均由接入方绘制) +typedef TCalendarCellBuilder = Widget? Function( + BuildContext context, + TCalendarCellModel cell, +); + +/// 单个日期格数据(只读,选中态通过 [typeNotifier] 更新) +class TCalendarCellModel { + TCalendarCellModel({ + required this.date, + required this.typeNotifier, + required this.isLastDayOfMonth, + }); + + final DateTime date; + final DateSelectTypeNotifier typeNotifier; + final bool isLastDayOfMonth; + + DateSelectType get selectType => typeNotifier.value; +} + +class DateSelectTypeNotifier extends ChangeNotifier { + DateSelectType value = DateSelectType.empty; + + DateSelectTypeNotifier(DateSelectType selectType) { + value = selectType; + } + + void setType(DateSelectType type) { + value = type; + notifyListeners(); + } +} class TCalendarCell extends StatefulWidget { const TCalendarCell({ Key? key, - this.tdate, - this.format, - required this.type, - this.onCellClick, - this.onCellLongPress, - this.onChange, + this.cell, + this.onTap, required this.height, - required this.data, required this.padding, required this.rowIndex, required this.colIndex, required this.dateList, - this.cellWidget, - this.dateType = TCalendarDateType.solar, - this.showLunarInfo = false, + this.cellBuilder, + this.subtitleBuilder, + this.dataSource, + this.dayStyle, + this.todayDayStyle, + this.subtitleStyle, }) : super(key: key); - final TDate? tdate; - final CalendarFormat? format; - final CalendarType type; - final void Function( - int value, - DateSelectType type, - TDate tdate, - )? onCellClick; - final void Function( - int value, - DateSelectType type, - TDate tdate, - )? onCellLongPress; - final void Function(List value)? onChange; + final TCalendarCellModel? cell; + + final void Function(TCalendarCellModel cell)? onTap; + final double height; - final Map> data; final double padding; final int rowIndex; final int colIndex; - final List dateList; - final Widget? Function( - BuildContext context, - TDate tdate, - DateSelectType selectType, - )? cellWidget; - final TCalendarDateType dateType; - final bool showLunarInfo; + final List dateList; + final TCalendarCellBuilder? cellBuilder; + final TCalendarSubtitleBuilder? subtitleBuilder; + final TCalendarDataSource? dataSource; + final TextStyle? dayStyle; + final TextStyle? todayDayStyle; + final TextStyle? subtitleStyle; @override - _TCalendarCellState createState() => _TCalendarCellState(); + State createState() => _TCalendarCellState(); } class _TCalendarCellState extends State { - var isToday = false; - var positionOffset = 0; + var _isToday = false; + var _positionOffset = 0; @override void initState() { super.initState(); - isToday = _isToday(); - widget.tdate?.typeNotifier.addListener(_cellTypeChange); + _isToday = _checkIsToday(); + widget.cell?.typeNotifier.addListener(_onSelectTypeChange); } @override void didUpdateWidget(TCalendarCell oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.tdate != oldWidget.tdate) { - isToday = _isToday(); - oldWidget.tdate?.typeNotifier.removeListener(_cellTypeChange); - widget.tdate?.typeNotifier.addListener(_cellTypeChange); + if (widget.cell != oldWidget.cell) { + _isToday = _checkIsToday(); + oldWidget.cell?.typeNotifier.removeListener(_onSelectTypeChange); + widget.cell?.typeNotifier.addListener(_onSelectTypeChange); } } @override void dispose() { - widget.tdate?.typeNotifier.removeListener(_cellTypeChange); + widget.cell?.typeNotifier.removeListener(_onSelectTypeChange); super.dispose(); } + bool _checkIsToday() { + final today = DateTime.now(); + return widget.cell?.date == + DateTime(today.year, today.month, today.day); + } + @override Widget build(BuildContext context) { - if (widget.tdate == null) { + final cell = widget.cell; + if (cell == null) { return const SizedBox.shrink(); } - final tdate = widget.format?.call(widget.tdate) ?? widget.tdate!; - final cellStyle = TCalendarStyle.cellStyle(context, widget.tdate!._type); - final decoration = tdate.decoration ?? cellStyle.cellDecoration; - final positionColor = _getColor(cellStyle, decoration); - // 新增自定义cell内容判断逻辑 - final content = - widget.cellWidget?.call(context, tdate, widget.tdate!._type) ?? - _buildDefaultCell(context, tdate, cellStyle); + final themedStyle = + TCalendarStyle.forSelectType(context, cell.selectType); + final decoration = + themedStyle.cellDecoration; + final positionColor = _rangeBridgeColor(themedStyle, decoration); + + final content = widget.cellBuilder?.call(context, cell) ?? + _buildDefaultCell(context, cell, themedStyle); return GestureDetector( behavior: HitTestBehavior.translucent, - onTap: _cellTap, - onLongPress: () { - final selectType = widget.tdate!._type; - final curDate = widget.tdate!._milliseconds; - widget.onCellLongPress?.call(curDate, selectType, widget.tdate!); - }, + onTap: () => widget.onTap?.call(cell), child: Stack( clipBehavior: Clip.none, children: [ @@ -113,14 +156,14 @@ class _TCalendarCellState extends State { width: double.infinity, height: widget.height, decoration: decoration, - child: content, // 使用自定义内容 + child: content, ), ), if (widget.colIndex < 6) Positioned( - right: -widget.padding - positionOffset, + right: -widget.padding - _positionOffset, child: Container( - width: widget.padding + 2 * positionOffset, + width: widget.padding + 2 * _positionOffset, height: widget.height, color: positionColor, ), @@ -130,281 +173,93 @@ class _TCalendarCellState extends State { ); } - void _cellTap() { - final list = widget.data.values.expand((element) => element).toList(); - final selectType = widget.tdate!._type; - final curDate = widget.tdate!._milliseconds; - if (selectType == DateSelectType.disabled) { - widget.onCellClick?.call(curDate, selectType, widget.tdate!); - return; - } - switch (widget.type) { - case CalendarType.single: - final date = - list.find((item) => item?._type == DateSelectType.selected); - date?._setType(DateSelectType.empty); - widget.tdate!._setType(DateSelectType.selected); - if (date?._milliseconds != curDate) { - widget.onChange?.call([curDate]); - } - break; - case CalendarType.multiple: - final date = list - .where((item) => item?._type == DateSelectType.selected) - .toList(); - final value = date.map((item) => item!._milliseconds).toList(); - if (date.find((item) => item?._milliseconds == curDate) != null) { - widget.tdate!._setType(DateSelectType.empty); - value.remove(curDate); - } else { - widget.tdate!._setType(DateSelectType.selected); - value.add(curDate); - } - widget.onChange?.call(value); - break; - case CalendarType.range: - final start = list.find((item) => item?._type == DateSelectType.start); - final end = list.find((item) => item?._type == DateSelectType.end); - final startTimes = start?._milliseconds; - if ((start == null && end == null) || - (start != null && end != null) || - (start != null && end == null && startTimes! >= curDate)) { - start?._setType(DateSelectType.empty); - end?._setType(DateSelectType.empty); - final centres = list - .where((item) => item?._type == DateSelectType.centre) - .toList(); - centres.forEach((item) => item!._setType(DateSelectType.empty)); - widget.tdate!._setType(DateSelectType.start); - widget.onChange?.call([curDate]); - } else if (start != null && end == null && startTimes! < curDate) { - start._setType(DateSelectType.start); - widget.tdate!._setType(DateSelectType.end); - var startIndex = list.indexOf(start) + 1; - while (list[startIndex] == null || - list[startIndex]!._milliseconds < curDate) { - list[startIndex]?._setType(DateSelectType.centre); - startIndex++; - } - widget.onChange?.call([startTimes, curDate]); - } - break; - } - widget.onCellClick?.call(curDate, widget.tdate!._type, widget.tdate!); - } - - void _cellTypeChange() { + void _onSelectTypeChange() { setState(() {}); } - Color? _getColor(TCalendarStyle cellStyle, BoxDecoration? decoration) { - positionOffset = 0; + Color? _rangeBridgeColor( + TCalendarStyle cellStyle, BoxDecoration? decoration) { + _positionOffset = 0; final next = _nextDay(); - if (widget.tdate?._type == DateSelectType.start) { - if (widget.tdate?.isLastDayOfMonth == true) { + if (widget.cell?.selectType == DateSelectType.start) { + if (widget.cell?.isLastDayOfMonth == true) { return null; } - if (next?._type == DateSelectType.end) { - positionOffset = 1; + if (next?.selectType == DateSelectType.end) { + _positionOffset = 1; return decoration?.color; } - if (next?._type == DateSelectType.centre) { + if (next?.selectType == DateSelectType.centre) { return cellStyle.centreColor; } } - if (widget.tdate?._type == DateSelectType.centre) { + if (widget.cell?.selectType == DateSelectType.centre) { return cellStyle.centreColor; } return null; } - TDate? _nextDay([int num = 1]) { - final index = widget.rowIndex * 7 + widget.colIndex + num; - final date = widget.dateList.getOrNull(index); - return date; + TCalendarCellModel? _nextDay([int offset = 1]) { + final index = widget.rowIndex * 7 + widget.colIndex + offset; + return widget.dateList.getOrNull(index); } - bool _isToday() { - final today = DateTime.now(); - return widget.tdate?._milliseconds == - DateTime(today.year, today.month, today.day).millisecondsSinceEpoch; - } - - /// 构建默认单元格内容 Widget _buildDefaultCell( - BuildContext context, TDate tdate, TCalendarStyle cellStyle) { - // 根据 dateType 和 showLunarInfo 决定显示内容 - String mainText = widget.tdate!.date.day.toString(); - String? subText; - - if (widget.dateType == TCalendarDateType.lunar && tdate.lunarInfo != null) { - // 农历模式:主文本显示农历,副文本显示阳历日期 - mainText = tdate.lunarInfo!.dayText; - subText = widget.tdate!.date.day.toString(); - } else if (widget.dateType == TCalendarDateType.solar && - widget.showLunarInfo) { - // 阳历模式+显示农历信息 - mainText = widget.tdate!.date.day.toString(); - - // 优先级:节日 > 节气 > 农历日期 - if (tdate.festival != null && tdate.festival!.isNotEmpty) { - // 显示节日 - subText = tdate.festival; - } else if (tdate.solarTerm != null && tdate.solarTerm!.isNotEmpty) { - // 显示节气 - subText = tdate.solarTerm; - } else if (tdate.lunarInfo != null) { - // 显示农历日期 - subText = tdate.lunarInfo!.dayText; - } - } + BuildContext context, + TCalendarCellModel cell, + TCalendarStyle cellStyle, + ) { + final dayText = cell.date.day.toString(); + final dayTextStyle = (_isToday ? cellStyle.todayDayStyle : null) ?? + widget.todayDayStyle ?? + cellStyle.dayStyle ?? + widget.dayStyle; + + final subtitle = _buildSubtitle(context, cell, cellStyle); return Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.center, children: [ - // prefix 区域 - 不强制高度 - if (tdate.prefix != null || tdate.prefixWidget != null) - SizedBox( - height: 12, - child: tdate.prefixWidget ?? - TText( - tdate.prefix ?? '', - style: tdate.prefixStyle ?? cellStyle.cellPrefixStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - // 主内容区域 - 自适应高度 - Expanded( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TText( - forceVerticalCenter: subText == null, - mainText, - style: (isToday ? cellStyle.todayStyle : null) ?? - tdate.style ?? - cellStyle.cellStyle, - ), - if (subText != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: TText( - subText, - style: cellStyle.cellSuffixStyle?.copyWith(fontSize: 9), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), + TText( + dayText, + forceVerticalCenter: subtitle == null, + style: dayTextStyle, ), - // suffix 区域 - 不强制高度 - if (tdate.suffix != null || tdate.suffixWidget != null) - SizedBox( - height: 12, - child: tdate.suffixWidget ?? - TText( - tdate.suffix ?? '', - style: tdate.suffixStyle ?? cellStyle.cellSuffixStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + if (subtitle != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: subtitle, ), ], ); } -} - -/// 时间对象 -class TDate { - TDate({ - required this.date, - required this.typeNotifier, - this.prefix, - this.prefixStyle, - this.prefixWidget, - this.suffix, - this.suffixStyle, - this.suffixWidget, - this.style, - this.decoration, - required this.isLastDayOfMonth, - this.lunarInfo, - this.solarTerm, - this.festival, - this.holidayInfo, - }); - - /// 时间对象 - final DateTime date; - - /// 日期类型 - final DateSelectTypeNotifier typeNotifier; - - /// 日期前面的字符串 - String? prefix; - - /// 日期前面的字符串的样式 - TextStyle? prefixStyle; - - /// 日期前面的组件,优先级高于[prefix] - Widget? prefixWidget; - /// 日期后面的字符串 - String? suffix; - - /// 日期后面的字符串的样式 - TextStyle? suffixStyle; - - /// 日期后面的组件,优先级高于[suffix] - Widget? suffixWidget; - - /// 日期样式 - TextStyle? style; - - /// 日期Decoration - BoxDecoration? decoration; - - /// 是否是当月最后一天 - final bool isLastDayOfMonth; - - /// 农历信息 - final TLunarInfo? lunarInfo; - - /// 节气信息(如"春分"、"立夏") - final String? solarTerm; - - /// 节日信息(如"春节"、"中秋节") - final String? festival; - - /// 假期信息(包含类型和名称) - /// type: 'holiday' 或 'workday' - /// name: 假期名称 - final Map? holidayInfo; - - int get _milliseconds => - DateTime(date.year, date.month, date.day).millisecondsSinceEpoch; - - DateSelectType get _type => typeNotifier.value; - - void _setType(DateSelectType type) { - typeNotifier.setType(type); - } -} - -class DateSelectTypeNotifier extends ChangeNotifier { - DateSelectType value = DateSelectType.empty; + Widget? _buildSubtitle( + BuildContext context, + TCalendarCellModel cell, + TCalendarStyle cellStyle, + ) { + if (widget.subtitleBuilder != null) { + return widget.subtitleBuilder!( + context, + TCalendarSubtitleContext( + date: cell.date, + selectType: cell.selectType, + ), + ); + } - DateSelectTypeNotifier(DateSelectType selectType) { - value = selectType; - } + final text = widget.dataSource?.getSubtitle(cell.date); + if (text == null || text.isEmpty) { + return null; + } - void setType(DateSelectType type) { - value = type; - notifyListeners(); + return TText( + text, + style: cellStyle.subtitleStyle ?? + widget.subtitleStyle?.copyWith(fontSize: 9), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); } } diff --git a/tdesign-component/lib/src/components/calendar/t_calendar_data_source.dart b/tdesign-component/lib/src/components/calendar/t_calendar_data_source.dart index 9dece2a36..3a8316e9f 100644 --- a/tdesign-component/lib/src/components/calendar/t_calendar_data_source.dart +++ b/tdesign-component/lib/src/components/calendar/t_calendar_data_source.dart @@ -1,161 +1,8 @@ -import 't_lunar_date.dart'; - -/// 日历数据源接口 -/// -/// 开发者需要实现此接口来提供农历转换能力。 -/// 组件内部不包含农历算法和数据,完全依赖外部实现。 +/// 日历可选数据源:仅提供副标题文案(无 [subtitleBuilder] 时使用)。 +/// +/// 农历、节气、节日等均由接入方在 [TCalendar.subtitleBuilder] 或 +/// [getSubtitle] 中自行处理;组件主区默认只渲染阳历日数字。 abstract class TCalendarDataSource { - /// 获取指定阳历日期的农历信息 - /// - /// [solarDate] 阳历日期 - /// - /// 返回 null 表示不显示农历信息 - TLunarInfo? getLunarInfo(DateTime solarDate); - - /// 格式化日期文本 - /// - /// [date] 阳历日期 - /// [type] 日历类型 - /// [lunarInfo] 农历信息(可选) - /// - /// 返回格式化后的日期字符串 - String formatDate( - DateTime date, - TCalendarDateType type, [ - TLunarInfo? lunarInfo, - ]); - - /// 获取节气信息(可选实现) - /// - /// [date] 阳历日期 - /// - /// 返回节气名称,如"春分"、"秋分"等,无节气则返回 null - String? getSolarTerm(DateTime date) => null; - - /// 获取节日信息(可选实现) - /// - /// [date] 阳历日期 - /// [lunarInfo] 农历信息(可选) - /// - /// 返回节日名称,如"春节"、"中秋节"等,无节日则返回 null - String? getFestival(DateTime date, [TLunarInfo? lunarInfo]) => null; - - /// 获取假期信息(可选实现) - /// - /// [date] 阳历日期 - /// - /// 返回假期类型和名称: - /// - 'holiday': 法定节假日/公共假期(如"国庆节") - /// - 'workday': 调休工作日(如"补班") - /// - null: 正常日期 - /// - /// 示例返回值: - /// - {'type': 'holiday', 'name': '国庆节'} - /// - {'type': 'workday', 'name': '补班'} - /// - null - Map? getHolidayInfo(DateTime date) => null; - - /// 格式化年份文本 - /// - /// [year] 年份 - /// [type] 日历类型 - /// - /// 返回格式化后的年份字符串 - /// 阳历示例:2025 -> "2025年" - /// 阴历示例:2025 -> "二〇二五年" - String formatYear(int year, TCalendarDateType type) { - if (type == TCalendarDateType.solar) { - return '$year年'; - } - return '${_convertToChineseNumber(year)}年'; - } - - /// 格式化月份文本 - /// - /// [month] 月份(1-12) - /// [type] 日历类型 - /// [isLeapMonth] 是否是闰月(仅农历有效) - /// - /// 返回格式化后的月份字符串 - /// 阳历示例:3 -> "3月" - /// 阴历示例:3 -> "三月",闰3月 -> "闰三月" - String formatMonth(int month, TCalendarDateType type, - [bool isLeapMonth = false]) { - if (type == TCalendarDateType.solar) { - return '$month月'; - } - const months = [ - '正月', - '二月', - '三月', - '四月', - '五月', - '六月', - '七月', - '八月', - '九月', - '十月', - '冬月', - '腊月' - ]; - final monthText = months[month - 1]; - return isLeapMonth ? '闰$monthText' : monthText; - } - - /// 格式化日期文本 - /// - /// [day] 日期(1-31) - /// [type] 日历类型 - /// - /// 返回格式化后的日期字符串 - /// 阳历示例:7 -> "7日" - /// 阴历示例:7 -> "初七" - String formatDay(int day, TCalendarDateType type) { - if (type == TCalendarDateType.solar) { - return '$day日'; - } - const days = [ - '初一', - '初二', - '初三', - '初四', - '初五', - '初六', - '初七', - '初八', - '初九', - '初十', - '十一', - '十二', - '十三', - '十四', - '十五', - '十六', - '十七', - '十八', - '十九', - '二十', - '廿一', - '廿二', - '廿三', - '廿四', - '廿五', - '廿六', - '廿七', - '廿八', - '廿九', - '三十' - ]; - return days[day - 1]; - } - - /// 将数字转换为中文数字 - String _convertToChineseNumber(int number) { - const digits = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九']; - return number - .toString() - .split('') - .map((d) => digits[int.parse(d)]) - .join(); - } + /// 副标题文案;返回 null 或空字符串时不显示副标题行。 + String? getSubtitle(DateTime date) => null; } diff --git a/tdesign-component/lib/src/components/calendar/t_calendar_header.dart b/tdesign-component/lib/src/components/calendar/t_calendar_header.dart index 92e259ab4..d3cfde6ad 100644 --- a/tdesign-component/lib/src/components/calendar/t_calendar_header.dart +++ b/tdesign-component/lib/src/components/calendar/t_calendar_header.dart @@ -1,5 +1,28 @@ import 'package:flutter/material.dart'; import '../../../tdesign_flutter.dart'; +import 't_calendar_cell.dart'; + +/// 为 [TCalendar.titleWidget] 应用 [TCalendarStyle.titleStyle] / [titleMaxLine]。 +Widget? wrapCalendarTitleWidget( + Widget? titleWidget, { + TextStyle? titleStyle, + int? titleMaxLine, + TextOverflow titleOverflow = TextOverflow.ellipsis, +}) { + if (titleWidget == null) { + return null; + } + if (titleStyle == null && titleMaxLine == null) { + return titleWidget; + } + return DefaultTextStyle.merge( + style: titleStyle, + maxLines: titleMaxLine, + overflow: titleOverflow, + textAlign: TextAlign.center, + child: titleWidget, + ); +} class TCalendarHeader extends StatelessWidget { const TCalendarHeader({ @@ -9,15 +32,13 @@ class TCalendarHeader extends StatelessWidget { required this.padding, this.weekdayStyle, required this.weekdayHeight, - this.title, - this.titleStyle, this.titleWidget, + this.titleStyle, this.titleMaxLine, this.titleOverflow, this.closeBtn = true, this.closeColor, this.onClose, - this.onClick, required this.weekdayNames, }) : super(key: key); @@ -26,16 +47,14 @@ class TCalendarHeader extends StatelessWidget { final double padding; final TextStyle? weekdayStyle; final double weekdayHeight; - final String? title; - final TextStyle? titleStyle; final Widget? titleWidget; + final TextStyle? titleStyle; final int? titleMaxLine; final TextOverflow? titleOverflow; final bool closeBtn; final Color? closeColor; final VoidCallback? onClose; final List weekdayNames; - final void Function(int index, String week)? onClick; List _getWeeks(BuildContext context) { final ans = []; @@ -54,7 +73,7 @@ class TCalendarHeader extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: padding), child: Column( children: [ - if (title?.isNotEmpty == true || titleWidget != null || closeBtn) + if (titleWidget != null || closeBtn) Container( padding: EdgeInsets.symmetric(vertical: padding), child: Row( @@ -62,13 +81,14 @@ class TCalendarHeader extends StatelessWidget { if (closeBtn) const SizedBox(width: 24), Expanded( child: Center( - child: titleWidget ?? - TText( - title, - style: titleStyle, - maxLines: titleMaxLine, - overflow: TextOverflow.ellipsis, - ), + child: wrapCalendarTitleWidget( + titleWidget, + titleStyle: titleStyle, + titleMaxLine: titleMaxLine, + titleOverflow: + titleOverflow ?? TextOverflow.ellipsis, + ) ?? + const SizedBox.shrink(), ), ), if (closeBtn) @@ -89,17 +109,12 @@ class TCalendarHeader extends StatelessWidget { for (int index = 0; index < list.length; index++) ...[ if (index != 0) SizedBox(width: weekdayGap), Expanded( - child: GestureDetector( - onTap: () { - onClick?.call(index, list[index]); - }, - child: SizedBox( - height: weekdayHeight, - child: Center( - child: TText( - list[index], - style: weekdayStyle, - ), + child: SizedBox( + height: weekdayHeight, + child: Center( + child: TText( + list[index], + style: weekdayStyle, ), ), ), diff --git a/tdesign-component/lib/src/components/calendar/t_calendar_popup.dart b/tdesign-component/lib/src/components/calendar/t_calendar_popup.dart deleted file mode 100644 index 731736375..000000000 --- a/tdesign-component/lib/src/components/calendar/t_calendar_popup.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../../tdesign_flutter.dart'; - -typedef CalendarBuilder = Widget Function(BuildContext context); - -/// 单元格组件popup模式 -class TCalendarPopup { - TCalendarPopup( - this.context, { - this.top, - this.autoClose = true, - this.confirmBtn, - this.visible, - this.onClose, - this.onConfirm, - this.builder, - this.child, - }) { - if (builder == null && child == null) { - throw FlutterError('[TCalendarPopup] builder or child must be not null'); - } - if (visible == true) { - show(); - } - } - - /// 上下文 - final BuildContext context; - - /// 距离顶部的距离 - final double? top; - - /// 自动关闭;在点击关闭按钮、确认按钮、遮罩层时自动关闭 - final bool? autoClose; - - /// 自定义确认按钮 - final Widget? confirmBtn; - - /// 默认是否显示日历 - final bool? visible; - - /// 关闭时触发 - final VoidCallback? onClose; - - /// 控件构建器,优先级高于[child] - final CalendarBuilder? builder; - - /// 日历控件 - final TCalendar? child; - - /// 点击确认按钮时触发 - final void Function(List value)? onConfirm; - - static TPopupHandle? _calendarHandle; - - /// 当前选中值 - final ValueNotifier> _selected = ValueNotifier>([]); - - bool get _autoClose => autoClose ?? true; - - /// 当前选中值 - List get selected => _selected.value; - - /// 打开日历 - void show() { - if (_calendarHandle?.isShowing == true) { - return; - } - final childWidget = builder?.call(context) ?? child; - final topInset = top?.clamp(0.0, double.infinity).toDouble(); - final maxHeight = topInset == null - ? null - : (MediaQuery.sizeOf(context).height - topInset) - .clamp(0.0, double.infinity) - .toDouble(); - _calendarHandle = TPopup.show( - context, - options: TPopupOptions.bottom( - cancelBuilder: null, - confirmBuilder: null, - closeOnOverlayClick: false, - onOverlayClick: () { - if (_autoClose) { - close(); - } - }, - onClosed: _deleteRouter, - child: TCalendarInherited( - selected: _selected, - usePopup: true, - confirmBtn: confirmBtn, - onClose: _onClose, - onConfirm: _onConfirm, - child: maxHeight == null - ? childWidget! - : ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxHeight), - child: childWidget!, - ), - ), - ), - ); - } - - void _onClose() { - if (_autoClose) { - close(); - } - } - - void _onConfirm() { - onConfirm?.call(_selected.value); - if (_autoClose) { - close(); - } - } - - /// 关闭日历 - void close() { - _calendarHandle?.close(); - } - - void _deleteRouter() { - _calendarHandle = null; - onClose?.call(); - } -} - -class TCalendarInherited extends InheritedWidget { - const TCalendarInherited({ - required Widget child, - this.onClose, - required this.selected, - this.usePopup = true, - this.onConfirm, - this.confirmBtn, - Key? key, - }) : super(child: child, key: key); - - final VoidCallback? onClose; - final ValueNotifier> selected; - final bool? usePopup; - final VoidCallback? onConfirm; - final Widget? confirmBtn; - - @override - bool updateShouldNotify(covariant TCalendarInherited oldWidget) { - return false; - } - - static TCalendarInherited? of(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType(); - } -} diff --git a/tdesign-component/lib/src/components/calendar/t_calendar_style.dart b/tdesign-component/lib/src/components/calendar/t_calendar_style.dart index 5a53e1d30..5f792e146 100644 --- a/tdesign-component/lib/src/components/calendar/t_calendar_style.dart +++ b/tdesign-component/lib/src/components/calendar/t_calendar_style.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../../tdesign_flutter.dart'; +import 't_calendar_cell.dart'; /// 日历组件样式 class TCalendarStyle { @@ -10,19 +11,19 @@ class TCalendarStyle { this.titleCloseColor, this.weekdayStyle, this.monthTitleStyle, - this.cellStyle, + this.dayStyle, + this.todayDayStyle, this.centreColor, this.cellDecoration, - this.cellPrefixStyle, - this.cellSuffixStyle, + this.subtitleStyle, }); BoxDecoration? decoration; - /// header区域 [TCalendar.title]的样式 + /// header区域 [TCalendar.titleWidget]的样式 TextStyle? titleStyle; - /// header区域 [TCalendar.title]的行数 + /// header区域 [TCalendar.titleWidget]的行数 int? titleMaxLine; /// header区域 关闭图标的颜色 @@ -34,11 +35,11 @@ class TCalendarStyle { /// body区域 年月文字样式 TextStyle? monthTitleStyle; - /// 日期样式 - TextStyle? cellStyle; + /// 日期主区(默认阳历日数字)样式 + TextStyle? dayStyle; - /// 当天日期样式 - TextStyle? todayStyle; + /// 今天日期主区样式 + TextStyle? todayDayStyle; /// 日期decoration BoxDecoration? cellDecoration; @@ -46,11 +47,8 @@ class TCalendarStyle { /// 日期范围内背景样式 Color? centreColor; - /// 日期前面的字符串的样式 - TextStyle? cellPrefixStyle; - - /// 日期后面的字符串的样式 - TextStyle? cellSuffixStyle; + /// 副标题样式(仅 [TCalendarDataSource.getSubtitle] 字符串路径使用) + TextStyle? subtitleStyle; /// 日期垂直间距,水平间距为[verticalGap] / 2 double? verticalGap; @@ -86,15 +84,15 @@ class TCalendarStyle { bodyPadding = TTheme.of(context).spacer16; } - /// 日期样式 - TCalendarStyle.cellStyle(BuildContext context, DateSelectType? type) { + /// 按选中态生成单元格样式 + TCalendarStyle.forSelectType(BuildContext context, DateSelectType? type) { final radius6 = TTheme.of(context).radiusDefault; final defStyle = TextStyle( fontSize: TTheme.of(context).fontTitleMedium?.size, height: TTheme.of(context).fontTitleMedium?.height, fontWeight: TTheme.of(context).fontTitleMedium?.fontWeight, ); - final prefixStyle = TextStyle( + final subtitleBase = TextStyle( fontSize: TTheme.of(context).fontBodyExtraSmall?.size, height: TTheme.of(context).fontBodyExtraSmall?.height, fontWeight: FontWeight.w400, @@ -102,64 +100,54 @@ class TCalendarStyle { centreColor = TTheme.of(context).brandLightColor; switch (type) { case DateSelectType.empty: - cellStyle = + dayStyle = defStyle.copyWith(color: TTheme.of(context).textColorPrimary); - todayStyle = defStyle.copyWith(color: TTheme.of(context).brandNormalColor); - cellPrefixStyle = - prefixStyle.copyWith(color: TTheme.of(context).errorNormalColor); - cellSuffixStyle = prefixStyle.copyWith( + todayDayStyle = + defStyle.copyWith(color: TTheme.of(context).brandNormalColor); + subtitleStyle = subtitleBase.copyWith( color: TTheme.of(context).textColorPlaceholder); cellDecoration = null; break; case DateSelectType.disabled: - cellStyle = + dayStyle = defStyle.copyWith(color: TTheme.of(context).textDisabledColor); - todayStyle = defStyle.copyWith(color: TTheme.of(context).brandDisabledColor); - cellPrefixStyle = - prefixStyle.copyWith(color: TTheme.of(context).errorDisabledColor); - cellSuffixStyle = - prefixStyle.copyWith(color: TTheme.of(context).textDisabledColor); + todayDayStyle = + defStyle.copyWith(color: TTheme.of(context).brandDisabledColor); + subtitleStyle = + subtitleBase.copyWith(color: TTheme.of(context).textDisabledColor); cellDecoration = null; break; case DateSelectType.selected: - cellStyle = defStyle.copyWith(color: TTheme.of(context).textColorAnti); - cellPrefixStyle = - prefixStyle.copyWith(color: TTheme.of(context).textColorAnti); - cellSuffixStyle = - prefixStyle.copyWith(color: TTheme.of(context).textColorAnti); + dayStyle = defStyle.copyWith(color: TTheme.of(context).textColorAnti); + subtitleStyle = + subtitleBase.copyWith(color: TTheme.of(context).textColorAnti); cellDecoration = BoxDecoration( borderRadius: BorderRadius.circular(radius6), color: TTheme.of(context).brandNormalColor, ); break; case DateSelectType.centre: - cellStyle = + dayStyle = defStyle.copyWith(color: TTheme.of(context).textColorPrimary); - cellPrefixStyle = - prefixStyle.copyWith(color: TTheme.of(context).errorNormalColor); - cellSuffixStyle = prefixStyle.copyWith( + subtitleStyle = subtitleBase.copyWith( color: TTheme.of(context).textColorPlaceholder); cellDecoration = BoxDecoration( color: centreColor, ); break; case DateSelectType.start: - cellStyle = defStyle.copyWith(color: TTheme.of(context).textColorAnti); - cellPrefixStyle = - prefixStyle.copyWith(color: TTheme.of(context).textColorAnti); - cellSuffixStyle = - prefixStyle.copyWith(color: TTheme.of(context).textColorAnti); + dayStyle = defStyle.copyWith(color: TTheme.of(context).textColorAnti); + subtitleStyle = + subtitleBase.copyWith(color: TTheme.of(context).textColorAnti); cellDecoration = BoxDecoration( color: TTheme.of(context).brandNormalColor, borderRadius: BorderRadius.horizontal(left: Radius.circular(radius6)), ); break; case DateSelectType.end: - cellStyle = defStyle.copyWith(color: TTheme.of(context).textColorAnti); - cellPrefixStyle = - prefixStyle.copyWith(color: TTheme.of(context).textColorAnti); - cellSuffixStyle = - prefixStyle.copyWith(color: TTheme.of(context).textColorAnti); + dayStyle = defStyle.copyWith(color: TTheme.of(context).textColorAnti); + subtitleStyle = + subtitleBase.copyWith(color: TTheme.of(context).textColorAnti); cellDecoration = BoxDecoration( color: TTheme.of(context).brandNormalColor, borderRadius: diff --git a/tdesign-component/lib/src/components/calendar/t_date_picker.dart b/tdesign-component/lib/src/components/calendar/t_date_picker.dart deleted file mode 100644 index 57dba918b..000000000 --- a/tdesign-component/lib/src/components/calendar/t_date_picker.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../tdesign_flutter.dart'; -import '../picker/no_wave_behavior.dart'; -import '../../util/context_extension.dart'; -import 'date_picker_model.dart'; - -/// 日期/时间选择器(供 TCalendar 内部使用) -/// -/// 精简版,仅提供 TCalendar 时间选择器所需功能 -class TDatePicker extends StatefulWidget { - final String? title; - final String? leftText; - final String? rightText; - final DatePickerModel model; - final double? pickerHeight; - final int? pickerItemCount; - final bool? isTimeUnit; - final void Function(Map)? onConfirm; - final void Function(int wheelIndex, int index)? onSelectedItemChanged; - - const TDatePicker({ - super.key, - this.title, - this.leftText, - this.rightText, - required this.model, - this.pickerHeight, - this.pickerItemCount, - this.isTimeUnit, - this.onConfirm, - this.onSelectedItemChanged, - }); - - @override - State createState() => _TDatePickerState(); -} - -class _TDatePickerState extends State { - late double _pickerHeight; - - @override - void initState() { - super.initState(); - _pickerHeight = widget.pickerHeight ?? 178; - widget.model.init(); - } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.title != null) - Padding( - padding: EdgeInsets.symmetric(horizontal: TTheme.of(context).spacer16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - GestureDetector( - onTap: () => Navigator.pop(context), - child: Text(widget.leftText ?? context.resource.cancel, style: TextStyle(color: TTheme.of(context).textColorSecondary)), - ), - Text(widget.title ?? '', style: TextStyle(fontWeight: FontWeight.w600)), - GestureDetector( - onTap: () { - widget.onConfirm?.call(widget.model.selected); - Navigator.pop(context); - }, - child: Text(widget.rightText ?? context.resource.confirm, style: TextStyle(color: TTheme.of(context).brandNormalColor)), - ), - ], - ), - ), - SizedBox( - height: _pickerHeight, - width: double.infinity, - child: Stack( - alignment: Alignment.center, - children: [ - Positioned( - top: (_pickerHeight - 40) / 2, - left: 16, - right: 16, - child: Container( - height: 40, - decoration: BoxDecoration( - color: TTheme.of(context).bgColorSecondaryContainer, - borderRadius: BorderRadius.circular(TTheme.of(context).radiusDefault), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Row( - children: List.generate( - widget.model.controllers.length, - (i) => Expanded(child: _buildColumn(i)), - ), - ), - ), - ], - ), - ), - ], - ); - } - - Widget _buildColumn(int colIndex) { - final data = widget.model.data[colIndex]; - if (data.isEmpty) return const SizedBox.shrink(); - - return MediaQuery.removePadding( - context: context, - removeTop: true, - child: ScrollConfiguration( - behavior: NoWaveBehavior(), - child: ListWheelScrollView.useDelegate( - itemExtent: _pickerHeight / (widget.pickerItemCount ?? 5), - diameterRatio: 100, - controller: widget.model.controllers[colIndex], - physics: const FixedExtentScrollPhysics(), - onSelectedItemChanged: (index) { - // 联动刷新 - if (colIndex < widget.model.data.length - 1) { - widget.model.refreshDataAndController(colIndex); - } - widget.onSelectedItemChanged?.call(colIndex, index); - }, - childDelegate: ListWheelChildBuilderDelegate( - childCount: data.length, - builder: (context, index) { - final content = data[index].toString(); - return Container( - alignment: Alignment.center, - height: _pickerHeight / (widget.pickerItemCount ?? 5), - width: double.infinity, - child: TItemWidget( - content: content, - fixedExtentScrollController: widget.model.controllers[colIndex], - colIndex: colIndex, - index: index, - itemHeight: _pickerHeight / (widget.pickerItemCount ?? 5), - ), - ); - }, - ), - ), - ), - ); - } -} diff --git a/tdesign-component/lib/src/components/date_time_picker/t_date_time_picker.dart b/tdesign-component/lib/src/components/date_time_picker/t_date_time_picker.dart new file mode 100644 index 000000000..e2fdc6e48 --- /dev/null +++ b/tdesign-component/lib/src/components/date_time_picker/t_date_time_picker.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; + +import '../../../tdesign_flutter.dart'; + +export 't_date_time_picker_model.dart'; + +/// 时间选择器 +/// +/// 基于 `TPicker` 的组合组件,仅负责日期/时间数据的列生成和回调转换, +/// 所有 UI 渲染和滚轮交互完全委托给 `TPicker`。 +/// +/// 通过 [mode] 控制列结构,支持快捷模式和数组精确模式。 +/// +/// 快捷模式: +/// ```dart +/// TDateTimePicker( +/// mode: DateTimePickerMode.date, +/// onConfirm: (v) => print(v.year), // 2026 +/// ) +/// ``` +/// +/// 数组模式(年月日 + 时分): +/// ```dart +/// TDateTimePicker( +/// mode: DateTimePickerMode.array(['date', 'minute']), +/// onConfirm: (v) => print(v.toDateTime()), +/// ) +/// ``` +/// +/// 附带星期列: +/// ```dart +/// TDateTimePicker( +/// mode: DateTimePickerMode.array(['date', 'week']), +/// onConfirm: (v) { +/// print(v.day); // 16 +/// print(v.week); // 1(周一) +/// // week 列为只读显示,随 day 变化自动联动,不可独立滚动选择 +/// }, +/// ) +/// ``` +class TDateTimePicker extends StatefulWidget { + const TDateTimePicker({ + super.key, + required this.mode, + this.format, + this.start, + this.end, + this.initialValue, + this.onCancel, + this.onChange, + this.onConfirm, + this.title, + this.titleWidget, + this.cancel, + this.confirm, + this.height, + this.itemCount, + }); + + /// 列结构模式(必填) + /// + /// 决定显示哪些列(年/月/日/时/分/秒/星期),参见 [DateTimePickerMode]。 + final DateTimePickerMode mode; + + /// 自定义列显示文案 + /// + /// 仅影响显示([TPickerOption.label]),不影响回调数据。 + /// 传入 `null` 时使用默认格式(数字 + 中文单位,如 "2026年"、"5月")。 + /// + /// ```dart + /// format: (column, value) { + /// if (column == DateTimeColumn.month) { + /// return value.toString().padLeft(2, '0'); + /// } + /// return '$value'; + /// } + /// ``` + final String Function(DateTimeColumn column, int value)? format; + + /// 可选范围起始日期 + /// + /// 当传 `null` 时,年列默认起始为「当前年 - + /// [DateTimePickerDataHelper.defaultYearOffset]」(默认 10 年前)。 + /// + /// 月、日、时、分、秒列在跨年/跨月时不会被裁剪。 + final DateTime? start; + + /// 可选范围结束日期 + /// + /// 当传 `null` 时,年列默认结束为「当前年 + + /// [DateTimePickerDataHelper.defaultYearOffset]」(默认 10 年后)。 + /// + /// 月、日、时、分、秒列在跨年/跨月时不会被裁剪。 + final DateTime? end; + + /// 初始选中值 + final DateTime? initialValue; + + /// 点击取消时触发 + final VoidCallback? onCancel; + + /// 选中值变化回调(滚动实时触发) + /// + /// 回调参数为 [TDateTimePickerValue],仅包含当前 [mode] 下涉及的列, + /// 其余字段为 `null`。 + final void Function(TDateTimePickerValue result)? onChange; + + /// 点击确认时触发 + /// + /// 回调参数为 [TDateTimePickerValue],仅包含当前 [mode] 下涉及的列, + /// 其余字段为 `null`。 + /// + /// 通过 [TDateTimePickerValue.toDateTime] 可将结果重组为 `DateTime`: + /// ```dart + /// onConfirm: (v) { + /// final dt = v.toDateTime(); + /// }, + /// ``` + final void Function(TDateTimePickerValue result)? onConfirm; + + /// 工具栏标题文字 + final String? title; + + /// 自定义标题组件(优先级高于 [title]) + final Widget? titleWidget; + + /// 工具栏左侧自定义插槽 + final Widget? cancel; + + /// 工具栏右侧自定义插槽 + final Widget? confirm; + + /// 面板视窗高度 + final double? height; + + /// 每屏显示条目数量 + final int? itemCount; + + @override + State createState() => _TDateTimePickerState(); +} + +class _TDateTimePickerState extends State { + late List _columns; + late DateTime _current; + late TPickerColumns _pickerColumns; + late List _initialValue; + + /// 上一次 onChange 的 values(用于判断年/月是否变化) + List _lastValues = const []; + + @override + void initState() { + super.initState(); + _columns = widget.mode.columns; + _current = widget.initialValue ?? DateTime.now(); + _pickerColumns = _buildPickerColumns(); + _initialValue = DateTimePickerDataHelper.buildInitialValue( + columns: _columns, + value: _current, + ); + _lastValues = List.of(_initialValue); + } + + @override + void didUpdateWidget(covariant TDateTimePicker oldWidget) { + super.didUpdateWidget(oldWidget); + final modeChanged = !_columnsEqual( + oldWidget.mode.columns, + widget.mode.columns, + ); + final initialValueChanged = oldWidget.initialValue != widget.initialValue; + final rangeChanged = + oldWidget.start != widget.start || oldWidget.end != widget.end; + final formatChanged = oldWidget.format != widget.format; + + if (!modeChanged && !initialValueChanged && !rangeChanged && !formatChanged) { + return; + } + + if (modeChanged) { + _columns = widget.mode.columns; + } + if (modeChanged || initialValueChanged) { + _current = widget.initialValue ?? DateTime.now(); + _initialValue = DateTimePickerDataHelper.buildInitialValue( + columns: _columns, + value: _current, + ); + _lastValues = List.of(_initialValue); + } + _pickerColumns = _buildPickerColumns(); + } + + TPickerColumns _buildPickerColumns() { + return DateTimePickerDataHelper.buildColumns( + columns: _columns, + start: widget.start, + end: widget.end, + current: _current, + format: widget.format, + ); + } + + TDateTimePickerValue _toResult(TPickerValue pickerValue) { + return DateTimePickerDataHelper.toResult( + columns: _columns, + values: pickerValue.values, + ); + } + + void _handleChange(TPickerValue pickerValue) { + final newValues = pickerValue.values; + + // 检查是否需要联动刷新(年/月变化 → 日列天数变化;日变化 → 星期变化) + if (DateTimePickerDataHelper.needsRefresh( + _columns, + _lastValues, + newValues, + ) || + _needsWeekRefresh(newValues)) { + _current = DateTimePickerDataHelper.resolveCurrentDateTime( + columns: _columns, + values: newValues, + fallback: _current, + ); + setState(() { + _pickerColumns = _buildPickerColumns(); + // 更新 initialValue 以保持 TPicker 的选中位置 + _initialValue = DateTimePickerDataHelper.buildInitialValue( + columns: _columns, + value: _current, + ); + }); + } + + _lastValues = List.of(newValues); + widget.onChange?.call(_toResult(pickerValue)); + } + + /// 检查是否需要刷新星期列(日列变化时星期也需要更新) + bool _needsWeekRefresh(List newValues) { + if (!_columns.contains(DateTimeColumn.week)) { + return false; + } + for (var i = 0; i < _columns.length && i < _lastValues.length && i < newValues.length; i++) { + final col = _columns[i]; + if ((col == DateTimeColumn.year || + col == DateTimeColumn.month || + col == DateTimeColumn.day) && + _lastValues[i] != newValues[i]) { + return true; + } + } + return false; + } + + void _handleConfirm(TPickerValue pickerValue) { + widget.onConfirm?.call(_toResult(pickerValue)); + } + + @override + Widget build(BuildContext context) { + return TPicker( + items: _pickerColumns, + initialValue: _initialValue, + title: widget.title, + titleWidget: widget.titleWidget, + cancel: widget.cancel, + confirm: widget.confirm, + height: widget.height ?? 200, + itemCount: widget.itemCount ?? 5, + onCancel: widget.onCancel, + onChange: _handleChange, + onConfirm: _handleConfirm, + ); + } + + static bool _columnsEqual( + List a, List b) { + if (identical(a, b)) { + return true; + } + if (a.length != b.length) { + return false; + } + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) { + return false; + } + } + return true; + } +} diff --git a/tdesign-component/lib/src/components/date_time_picker/t_date_time_picker_model.dart b/tdesign-component/lib/src/components/date_time_picker/t_date_time_picker_model.dart new file mode 100644 index 000000000..a23144fb1 --- /dev/null +++ b/tdesign-component/lib/src/components/date_time_picker/t_date_time_picker_model.dart @@ -0,0 +1,707 @@ +import 'package:flutter/foundation.dart'; + +import '../picker/t_picker_items.dart'; +import '../picker/t_picker_option.dart'; + +/// 日期时间列类型枚举 +enum DateTimeColumn { + year, + month, + day, + hour, + minute, + second, + week, +} + +/// 时间选择器模式 +/// +/// 支持两种用法: +/// +/// 1. **快捷模式**(字符串)— 自动包含从年到指定粒度的所有列: +/// - `DateTimePickerMode.year` → 年 +/// - `DateTimePickerMode.month` → 年月 +/// - `DateTimePickerMode.date` → 年月日 +/// - `DateTimePickerMode.hour` → 年月日时 +/// - `DateTimePickerMode.minute` → 年月日时分 +/// - `DateTimePickerMode.second` → 年月日时分秒 +/// +/// 2. **数组模式** — 精确控制显示哪些列: +/// - 第一个值控制日期粒度(year/month/date),第二个值控制时间粒度(hour/minute/second) +/// - 支持附加 `week` 列:`DateTimePickerMode.array(['date', 'week'])` +/// +/// 示例: +/// ```dart +/// // 快捷:年月日 +/// DateTimePickerMode.date +/// +/// // 数组:年月日 + 时分 +/// DateTimePickerMode.array(['date', 'minute']) +/// +/// // 数组:年月日 + 星期 +/// DateTimePickerMode.array(['date', 'week']) +/// ``` +sealed class DateTimePickerMode { + const DateTimePickerMode(); + + /// 仅显示年 + static const year = ShortcutMode._('year'); + + /// 年月 + static const month = ShortcutMode._('month'); + + /// 年月日 + static const date = ShortcutMode._('date'); + + /// 年月日时 + static const hour = ShortcutMode._('hour'); + + /// 年月日时分 + static const minute = ShortcutMode._('minute'); + + /// 年月日时分秒 + static const second = ShortcutMode._('second'); + + /// 数组模式:精确控制列组合 + const factory DateTimePickerMode.array(List values) = ArrayMode; + + /// 解析 mode 为列类型列表 + List get columns; +} + +/// 快捷模式:按粒度自动包含从年到指定类型的所有列 +class ShortcutMode extends DateTimePickerMode { + const ShortcutMode._(this.value); + + final String value; + + static const _shortcutColumns = >{ + 'year': [DateTimeColumn.year], + 'month': [DateTimeColumn.year, DateTimeColumn.month], + 'date': [DateTimeColumn.year, DateTimeColumn.month, DateTimeColumn.day], + 'hour': [ + DateTimeColumn.year, + DateTimeColumn.month, + DateTimeColumn.day, + DateTimeColumn.hour, + ], + 'minute': [ + DateTimeColumn.year, + DateTimeColumn.month, + DateTimeColumn.day, + DateTimeColumn.hour, + DateTimeColumn.minute, + ], + 'second': [ + DateTimeColumn.year, + DateTimeColumn.month, + DateTimeColumn.day, + DateTimeColumn.hour, + DateTimeColumn.minute, + DateTimeColumn.second, + ], + }; + + @override + List get columns { + // 私有构造保证 value 必为 _shortcutColumns 中的合法 key + final cols = _shortcutColumns[value]; + assert(cols != null, 'ShortcutMode 未知 value: "$value",必须使用 DateTimePickerMode 提供的静态常量'); + return cols!; + } +} + +/// 数组模式:精确控制列组合 +/// +/// - 第一个值控制日期粒度(year/month/date) +/// - 第二个值控制时间粒度(hour/minute/second) +/// - 可附加 `week` 列 +class ArrayMode extends DateTimePickerMode { + const ArrayMode(this.values); + + final List values; + + /// 合法值集合 + static const _validValues = { + 'year', + 'month', + 'date', + 'hour', + 'minute', + 'second', + 'week', + }; + + static const _dateColumns = >{ + 'year': [DateTimeColumn.year], + 'month': [DateTimeColumn.year, DateTimeColumn.month], + 'date': [DateTimeColumn.year, DateTimeColumn.month, DateTimeColumn.day], + }; + + static const _timeColumns = >{ + 'hour': [DateTimeColumn.hour], + 'minute': [DateTimeColumn.hour, DateTimeColumn.minute], + 'second': [ + DateTimeColumn.hour, + DateTimeColumn.minute, + DateTimeColumn.second, + ], + }; + + @override + List get columns { + assert(values.isNotEmpty, 'ArrayMode.values 不能为空'); + final result = []; + final seen = {}; + for (final v in values) { + assert( + _validValues.contains(v), + 'ArrayMode 不支持的值: "$v",合法值: $_validValues', + ); + List? cols; + if (_dateColumns.containsKey(v)) { + cols = _dateColumns[v]; + } else if (_timeColumns.containsKey(v)) { + cols = _timeColumns[v]; + } else if (v == 'week') { + cols = const [DateTimeColumn.week]; + } + if (cols == null) { + continue; + } + // 去重保持顺序 + for (final c in cols) { + if (seen.add(c)) { + result.add(c); + } + } + } + return result; + } +} + +/// 日期时间选择器的回调结果 +/// +/// 所有字段均为 **nullable**——字段为 `null` 表示当前 [DateTimePickerMode] 下 +/// 未包含该列,不是"用户选了 null"。 +/// +/// 示例: +/// ```dart +/// TDateTimePicker( +/// mode: DateTimePickerMode.date, +/// onConfirm: (v) { +/// print(v.year); // 2025 +/// print(v.month); // 6 +/// print(v.day); // 15 +/// print(v.hour); // null(date 模式不含时) +/// }, +/// ) +/// ``` +/// +/// 通过 [toDateTime] 可将结果重组为 `DateTime`: +/// ```dart +/// onConfirm: (v) { +/// final dt = v.toDateTime(); // DateTime(2025, 6, 15) +/// }, +/// ``` +@immutable +class TDateTimePickerValue { + const TDateTimePickerValue({ + this.year, + this.month, + this.day, + this.hour, + this.minute, + this.second, + this.week, + }); + + /// 年(模式包含 year 列时有值) + final int? year; + + /// 月(模式包含 month 列时有值) + final int? month; + + /// 日(模式包含 day 列时有值) + final int? day; + + /// 时(模式包含 hour 列时有值) + final int? hour; + + /// 分(模式包含 minute 列时有值) + final int? minute; + + /// 秒(模式包含 second 列时有值) + final int? second; + + /// 星期(模式包含 week 列时有值,1=周一 … 7=周日) + /// + /// 注意:week 列为只读显示,随 year/month/day 变化自动联动, + /// 其值等价于 [toDateTime]?.weekday,不可独立选择。 + final int? week; + + /// 将选中结果重组为 [DateTime] + /// + /// 缺失字段使用 [fallback] 对应字段填充(默认 [DateTime.now])。 + /// + /// ```dart + /// // mode = DateTimePickerMode.date + /// // value = TDateTimePickerValue(year: 2025, month: 6, day: 15) + /// value.toDateTime(); // DateTime(2025, 6, 15, 0, 0, 0) + /// ``` + DateTime toDateTime({DateTime? fallback}) { + final fb = fallback ?? DateTime.now(); + return DateTime( + year ?? fb.year, + month ?? fb.month, + day ?? fb.day, + hour ?? fb.hour, + minute ?? fb.minute, + second ?? fb.second, + ); + } + + /// 从 [DateTimeColumn] 列表和对应的 int 值列表构建结果 + factory TDateTimePickerValue.fromColumns({ + required List columns, + required List values, + }) { + int? year, month, day, hour, minute, second, week; + for (var i = 0; i < columns.length && i < values.length; i++) { + final v = values[i]; + if (v is! int) { + continue; + } + switch (columns[i]) { + case DateTimeColumn.year: + year = v; + case DateTimeColumn.month: + month = v; + case DateTimeColumn.day: + day = v; + case DateTimeColumn.hour: + hour = v; + case DateTimeColumn.minute: + minute = v; + case DateTimeColumn.second: + second = v; + case DateTimeColumn.week: + week = v; + } + } + return TDateTimePickerValue( + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + week: week, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TDateTimePickerValue && + year == other.year && + month == other.month && + day == other.day && + hour == other.hour && + minute == other.minute && + second == other.second && + week == other.week; + + @override + int get hashCode => Object.hash(year, month, day, hour, minute, second, week); + + @override + String toString() => + 'TDateTimePickerValue(year: $year, month: $month, day: $day, ' + 'hour: $hour, minute: $minute, second: $second, week: $week)'; +} + +/// 日期时间列数据生成器 +/// +/// 纯数据工具类,根据列类型列表、范围和格式化函数生成 `TPickerColumns`。 +/// 不管理任何 UI 状态(controller 等由 `TPicker` 内部处理)。 +abstract final class DateTimePickerDataHelper { + /// 默认年范围相对于当前年份的偏移(前后各 10 年) + static const int defaultYearOffset = 10; + + /// 计算默认起始年(当前年 - [defaultYearOffset]) + static int defaultStartYear([DateTime? now]) => + (now ?? DateTime.now()).year - defaultYearOffset; + + /// 计算默认结束年(当前年 + [defaultYearOffset]) + static int defaultEndYear([DateTime? now]) => + (now ?? DateTime.now()).year + defaultYearOffset; + + /// 默认单位 + static const Map defaultUnits = { + DateTimeColumn.year: '年', + DateTimeColumn.month: '月', + DateTimeColumn.day: '日', + DateTimeColumn.hour: '时', + DateTimeColumn.minute: '分', + DateTimeColumn.second: '秒', + DateTimeColumn.week: '', + }; + + /// 星期文案 + static const List weekLabels = [ + '周一', + '周二', + '周三', + '周四', + '周五', + '周六', + '周日', + ]; + + /// 根据列类型、范围和当前选中值生成 [TPickerColumns] + /// + /// [columns] 需要生成的列类型列表 + /// [start] 可选范围起始 + /// [end] 可选范围结束 + /// [current] 当前选中值(用于计算日列天数和星期列) + /// [format] 自定义格式化函数 + /// + /// 当 [start] 晚于 [end] 时:debug 模式下会触发 assert,release 模式下会忽略 [end], + /// 仅以 [start] 作为下界,避免崩溃。 + static TPickerColumns buildColumns({ + required List columns, + DateTime? start, + DateTime? end, + DateTime? current, + String Function(DateTimeColumn column, int value)? format, + }) { + assert( + start == null || end == null || !start.isAfter(end), + 'DateTimePickerDataHelper.buildColumns: start ($start) 不能晚于 end ($end)', + ); + // release 模式下若 start > end,丢弃 end 以避免空区间崩溃 + final safeEnd = (start != null && end != null && start.isAfter(end)) + ? null + : end; + final now = current ?? DateTime.now(); + final result = >[]; + + for (final col in columns) { + switch (col) { + case DateTimeColumn.year: + result.add(_buildYearColumn(start, safeEnd, now, format)); + case DateTimeColumn.month: + result.add(_buildMonthColumn(start, safeEnd, now, format)); + case DateTimeColumn.day: + result.add(_buildDayColumn(start, safeEnd, now, format)); + case DateTimeColumn.hour: + result.add(_buildHourColumn(start, safeEnd, now, format)); + case DateTimeColumn.minute: + result.add(_buildMinuteColumn(start, safeEnd, now, format)); + case DateTimeColumn.second: + result.add(_buildSecondColumn(start, safeEnd, now, format)); + case DateTimeColumn.week: + result.add(_buildWeekColumn(now, format)); + } + } + + return TPickerColumns(result); + } + + /// 根据列类型列表和当前选中值,计算 `TPicker` 的 initialValue + static List buildInitialValue({ + required List columns, + required DateTime value, + }) { + final result = []; + for (final col in columns) { + switch (col) { + case DateTimeColumn.year: + result.add(value.year); + case DateTimeColumn.month: + result.add(value.month); + case DateTimeColumn.day: + result.add(value.day); + case DateTimeColumn.hour: + result.add(value.hour); + case DateTimeColumn.minute: + result.add(value.minute); + case DateTimeColumn.second: + result.add(value.second); + case DateTimeColumn.week: + result.add(value.weekday); + } + } + return result; + } + + /// 将 TPicker 回调的 values 转为 [TDateTimePickerValue] + /// + /// 推荐直接使用 [TDateTimePickerValue.fromColumns],此方法为便利封装。 + static TDateTimePickerValue toResult({ + required List columns, + required List values, + }) { + return TDateTimePickerValue.fromColumns( + columns: columns, + values: values, + ); + } + + /// 从 TPicker 回调的 values 中恢复出当前选中的 DateTime + /// + /// 用于联动刷新时重新计算日列天数和星期列。 + static DateTime resolveCurrentDateTime({ + required List columns, + required List values, + DateTime? fallback, + }) { + final fb = fallback ?? DateTime.now(); + var year = fb.year; + var month = fb.month; + var day = fb.day; + var hour = fb.hour; + var minute = fb.minute; + var second = fb.second; + + for (var i = 0; i < columns.length && i < values.length; i++) { + final v = values[i]; + if (v is! int) { + continue; + } + switch (columns[i]) { + case DateTimeColumn.year: + year = v; + case DateTimeColumn.month: + month = v; + case DateTimeColumn.day: + day = v; + case DateTimeColumn.hour: + hour = v; + case DateTimeColumn.minute: + minute = v; + case DateTimeColumn.second: + second = v; + case DateTimeColumn.week: + break; + } + } + + // 修正日期(如 2 月 30 日 → 2 月 28/29 日) + final maxDay = _daysInMonth(year, month); + if (day > maxDay) { + day = maxDay; + } + + return DateTime(year, month, day, hour, minute, second); + } + + /// 检查年/月列是否发生变化(决定是否需要联动刷新) + static bool needsRefresh( + List columns, + List oldValues, + List newValues, + ) { + for (var i = 0; i < columns.length && i < oldValues.length && i < newValues.length; i++) { + final col = columns[i]; + if ((col == DateTimeColumn.year || col == DateTimeColumn.month) && + oldValues[i] != newValues[i]) { + return true; + } + } + return false; + } + + // ──────── 列数据生成 ──────── + + static List _buildYearColumn( + DateTime? start, + DateTime? end, + DateTime current, + String Function(DateTimeColumn, int)? format, + ) { + final startYear = start?.year ?? defaultStartYear(current); + final endYear = end?.year ?? defaultEndYear(current); + return [ + for (var y = startYear; y <= endYear; y++) + TPickerOption( + label: format?.call(DateTimeColumn.year, y) ?? + '$y${defaultUnits[DateTimeColumn.year]}', + value: y, + ), + ]; + } + + static List _buildMonthColumn( + DateTime? start, + DateTime? end, + DateTime current, + String Function(DateTimeColumn, int)? format, + ) { + var startMonth = 1; + var endMonth = 12; + + // 当前年 == 起始年时,裁剪起始月 + if (start != null && current.year == start.year) { + startMonth = start.month; + } + // 当前年 == 结束年时,裁剪结束月 + if (end != null && current.year == end.year) { + endMonth = end.month; + } + + return [ + for (var m = startMonth; m <= endMonth; m++) + TPickerOption( + label: format?.call(DateTimeColumn.month, m) ?? + '$m${defaultUnits[DateTimeColumn.month]}', + value: m, + ), + ]; + } + + static List _buildDayColumn( + DateTime? start, + DateTime? end, + DateTime current, + String Function(DateTimeColumn, int)? format, + ) { + final maxDay = _daysInMonth(current.year, current.month); + var startDay = 1; + var endDay = maxDay; + + if (start != null && + current.year == start.year && + current.month == start.month) { + startDay = start.day; + } + if (end != null && + current.year == end.year && + current.month == end.month) { + endDay = end.day.clamp(1, maxDay); + } + + return [ + for (var d = startDay; d <= endDay; d++) + TPickerOption( + label: format?.call(DateTimeColumn.day, d) ?? + '$d${defaultUnits[DateTimeColumn.day]}', + value: d, + ), + ]; + } + + static List _buildHourColumn( + DateTime? start, + DateTime? end, + DateTime current, + String Function(DateTimeColumn, int)? format, + ) { + var startHour = 0; + var endHour = 23; + + if (start != null && _isSameDate(current, start)) { + startHour = start.hour; + } + if (end != null && _isSameDate(current, end)) { + endHour = end.hour; + } + + return [ + for (var h = startHour; h <= endHour; h++) + TPickerOption( + label: format?.call(DateTimeColumn.hour, h) ?? + '$h${defaultUnits[DateTimeColumn.hour]}', + value: h, + ), + ]; + } + + static List _buildMinuteColumn( + DateTime? start, + DateTime? end, + DateTime current, + String Function(DateTimeColumn, int)? format, + ) { + var startMinute = 0; + var endMinute = 59; + + if (start != null && + _isSameDate(current, start) && + current.hour == start.hour) { + startMinute = start.minute; + } + if (end != null && + _isSameDate(current, end) && + current.hour == end.hour) { + endMinute = end.minute; + } + + return [ + for (var m = startMinute; m <= endMinute; m++) + TPickerOption( + label: format?.call(DateTimeColumn.minute, m) ?? + '$m${defaultUnits[DateTimeColumn.minute]}', + value: m, + ), + ]; + } + + static List _buildSecondColumn( + DateTime? start, + DateTime? end, + DateTime current, + String Function(DateTimeColumn, int)? format, + ) { + var startSecond = 0; + var endSecond = 59; + + if (start != null && + _isSameDate(current, start) && + current.hour == start.hour && + current.minute == start.minute) { + startSecond = start.second; + } + if (end != null && + _isSameDate(current, end) && + current.hour == end.hour && + current.minute == end.minute) { + endSecond = end.second; + } + + return [ + for (var s = startSecond; s <= endSecond; s++) + TPickerOption( + label: format?.call(DateTimeColumn.second, s) ?? + '$s${defaultUnits[DateTimeColumn.second]}', + value: s, + ), + ]; + } + + static List _buildWeekColumn( + DateTime current, + String Function(DateTimeColumn, int)? format, + ) { + // 星期列固定只显示当前日期对应的星期几 + final weekday = current.weekday; // 1=周一 ... 7=周日 + return [ + TPickerOption( + label: format?.call(DateTimeColumn.week, weekday) ?? + weekLabels[weekday - 1], + value: weekday, + ), + ]; + } + + // ──────── 工具方法 ──────── + + static int _daysInMonth(int year, int month) { + return DateTime(year, month + 1, 0).day; + } + + static bool _isSameDate(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } +} diff --git a/tdesign-component/lib/src/components/dialog/t_dialog_widget.dart b/tdesign-component/lib/src/components/dialog/t_dialog_widget.dart index 71fce313b..0bdc608ae 100644 --- a/tdesign-component/lib/src/components/dialog/t_dialog_widget.dart +++ b/tdesign-component/lib/src/components/dialog/t_dialog_widget.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import '../../../tdesign_flutter.dart'; +import 't_dialog.dart'; /// TDialog手脚架 class TDialogScaffold extends StatelessWidget { diff --git a/tdesign-component/lib/src/components/popup/_popup_route.dart b/tdesign-component/lib/src/components/popup/_popup_route.dart index 6acf18cab..1efb0b507 100644 --- a/tdesign-component/lib/src/components/popup/_popup_route.dart +++ b/tdesign-component/lib/src/components/popup/_popup_route.dart @@ -151,15 +151,12 @@ class _PopupNavigatorRoute extends PopupRoute { final barrier = _buildBarrier(context, t); - return Material( - type: MaterialType.transparency, - child: Stack( - fit: StackFit.expand, - children: [ - if (_barrierMode == _PopupBarrierMode.modalOverlay) barrier, - positioned, - ], - ), + return Stack( + fit: StackFit.expand, + children: [ + if (_barrierMode == _PopupBarrierMode.modalOverlay) barrier, + positioned, + ], ); } diff --git a/tdesign-component/lib/src/theme/resource_delegate.dart b/tdesign-component/lib/src/theme/resource_delegate.dart index 3119544e0..bd7f34e0b 100644 --- a/tdesign-component/lib/src/theme/resource_delegate.dart +++ b/tdesign-component/lib/src/theme/resource_delegate.dart @@ -109,76 +109,76 @@ abstract class TResourceDelegate { /// [TTimeCounter] 毫秒 String get milliseconds; - /// [TDatePicker] 年 + /// 年 String get yearLabel; - /// [TDatePicker] 月 + /// 月 String get monthLabel; - /// [TDatePicker] 日 + /// 日 String get dateLabel; - /// [TDatePicker] 周 + /// 周 String get weeksLabel; - /// [TCalendarHeader] 星期日 + /// [TCalendar] 星期日 String get sunday; - /// [TCalendarHeader] 星期一 + /// [TCalendar] 星期一 String get monday; - /// [TCalendarHeader] 星期二 + /// [TCalendar] 星期二 String get tuesday; - /// [TCalendarHeader] 星期三 + /// [TCalendar] 星期三 String get wednesday; - /// [TCalendarHeader] 星期四 + /// [TCalendar] 星期四 String get thursday; - /// [TCalendarHeader] 星期五 + /// [TCalendar] 星期五 String get friday; - /// [TCalendarHeader] 星期六 + /// [TCalendar] 星期六 String get saturday; - /// [TCalendarBody] 年 + /// [TCalendar] 年 String get year; - /// [TCalendarBody] 一月 + /// [TCalendar] 一月 String get january; - /// [TCalendarBody] 二月 + /// [TCalendar] 二月 String get february; - /// [TCalendarBody] 三月 + /// [TCalendar] 三月 String get march; - /// [TCalendarBody] 四月 + /// [TCalendar] 四月 String get april; - /// [TCalendarBody] 五月 + /// [TCalendar] 五月 String get may; - /// [TCalendarBody] 六月 + /// [TCalendar] 六月 String get june; - /// [TCalendarBody] 七月 + /// [TCalendar] 七月 String get july; - /// [TCalendarBody] 八月 + /// [TCalendar] 八月 String get august; - /// [TCalendarBody] 九月 + /// [TCalendar] 九月 String get september; - /// [TCalendarBody] 十月 + /// [TCalendar] 十月 String get october; - /// [TCalendarBody] 十一月 + /// [TCalendar] 十一月 String get november; - /// [TCalendarBody] 十二月 + /// [TCalendar] 十二月 String get december; /// [TCalendar] 时间 diff --git a/tdesign-component/lib/tdesign_flutter.dart b/tdesign-component/lib/tdesign_flutter.dart index 548aa5a5b..7a004cbd6 100644 --- a/tdesign-component/lib/tdesign_flutter.dart +++ b/tdesign-component/lib/tdesign_flutter.dart @@ -16,6 +16,7 @@ export 'src/components/checkbox/t_check_box.dart'; export 'src/components/checkbox/t_check_box_group.dart'; export 'src/components/collapse/t_collapse.dart'; export 'src/components/collapse/t_collapse_panel.dart'; +export 'src/components/date_time_picker/t_date_time_picker.dart'; export 'src/components/dialog/t_dialog.dart'; export 'src/components/divider/t_divider.dart'; export 'src/components/drawer/t_drawer.dart'; diff --git a/tdesign-component/test/t_calendar_lunar_test.dart b/tdesign-component/test/t_calendar_lunar_test.dart index b9607c7b5..ee9946335 100644 --- a/tdesign-component/test/t_calendar_lunar_test.dart +++ b/tdesign-component/test/t_calendar_lunar_test.dart @@ -2,225 +2,39 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; void main() { - group('TLunarInfo', () { - test('should create lunar info correctly', () { - final lunarInfo = TLunarInfo( - year: 2025, - month: 3, - day: 7, - yearText: '二〇二五', - monthText: '三月', - dayText: '初七', - ); - - expect(lunarInfo.year, 2025); - expect(lunarInfo.month, 3); - expect(lunarInfo.day, 7); - expect(lunarInfo.isLeapMonth, false); - expect(lunarInfo.yearText, '二〇二五'); - expect(lunarInfo.monthText, '三月'); - expect(lunarInfo.dayText, '初七'); - expect(lunarInfo.fullText, '二〇二五年 三月初七'); - }); - - test('should handle leap month correctly', () { - final lunarInfo = TLunarInfo( - year: 2025, - month: 3, - day: 7, - isLeapMonth: true, - yearText: '二〇二五', - monthText: '闰三月', - dayText: '初七', - ); - - expect(lunarInfo.isLeapMonth, true); - expect(lunarInfo.monthText, '闰三月'); - expect(lunarInfo.fullText, '二〇二五年 闰三月初七'); - }); - - test('should compare lunar info correctly', () { - final info1 = TLunarInfo( - year: 2025, - month: 3, - day: 7, - yearText: '二〇二五', - monthText: '三月', - dayText: '初七', - ); - - final info2 = TLunarInfo( - year: 2025, - month: 3, - day: 7, - yearText: '二〇二五', - monthText: '三月', - dayText: '初七', - ); - - final info3 = TLunarInfo( - year: 2025, - month: 3, - day: 8, - yearText: '二〇二五', - monthText: '三月', - dayText: '初八', - ); - - expect(info1, equals(info2)); - expect(info1, isNot(equals(info3))); - expect(info1.hashCode, equals(info2.hashCode)); - }); - }); - group('TCalendarDataSource', () { - test('should format year correctly', () { + test('getSubtitle 默认返回 null', () { final dataSource = _MockDataSource(); - - // 阳历 - expect( - dataSource.formatYear(2025, TCalendarDateType.solar), - '2025年', - ); - - // 农历 - expect( - dataSource.formatYear(2025, TCalendarDateType.lunar), - '二〇二五年', - ); + expect(dataSource.getSubtitle(DateTime(2025, 6, 15)), isNull); }); - test('should format month correctly', () { - final dataSource = _MockDataSource(); - - // 阳历 - expect( - dataSource.formatMonth(3, TCalendarDateType.solar), - '3月', - ); - - // 农历 - expect( - dataSource.formatMonth(1, TCalendarDateType.lunar), - '正月', - ); - expect( - dataSource.formatMonth(3, TCalendarDateType.lunar), - '三月', - ); + test('getSubtitle 可返回自定义副标题', () { + final dataSource = _SubtitleDataSource(); expect( - dataSource.formatMonth(11, TCalendarDateType.lunar), - '冬月', - ); - expect( - dataSource.formatMonth(12, TCalendarDateType.lunar), - '腊月', - ); - - // 闰月 - expect( - dataSource.formatMonth(3, TCalendarDateType.lunar, true), - '闰三月', - ); - }); - - test('should format day correctly', () { - final dataSource = _MockDataSource(); - - // 阳历 - expect( - dataSource.formatDay(7, TCalendarDateType.solar), - '7日', - ); - - // 农历 - expect( - dataSource.formatDay(1, TCalendarDateType.lunar), - '初一', - ); - expect( - dataSource.formatDay(7, TCalendarDateType.lunar), + dataSource.getSubtitle(DateTime(2025, 6, 15)), '初七', ); - expect( - dataSource.formatDay(15, TCalendarDateType.lunar), - '十五', - ); - expect( - dataSource.formatDay(21, TCalendarDateType.lunar), - '廿一', - ); - expect( - dataSource.formatDay(30, TCalendarDateType.lunar), - '三十', - ); }); }); - group('TDate with LunarInfo', () { - test('should create TDate with lunar info', () { - final lunarInfo = TLunarInfo( - year: 2025, - month: 3, - day: 7, - yearText: '二〇二五', - monthText: '三月', - dayText: '初七', - ); - - final tdate = TDate( - date: DateTime(2025, 4, 5), + group('TCalendarCellModel', () { + test('selectType 随 typeNotifier 更新', () { + final cell = TCalendarCellModel( + date: DateTime(2025, 6, 15), typeNotifier: DateSelectTypeNotifier(DateSelectType.empty), isLastDayOfMonth: false, - lunarInfo: lunarInfo, ); - expect(tdate.date, DateTime(2025, 4, 5)); - expect(tdate.lunarInfo, lunarInfo); - expect(tdate.lunarInfo?.dayText, '初七'); - }); - - test('should create TDate without lunar info', () { - final tdate = TDate( - date: DateTime(2025, 4, 5), - typeNotifier: DateSelectTypeNotifier(DateSelectType.empty), - isLastDayOfMonth: false, - ); - - expect(tdate.date, DateTime(2025, 4, 5)); - expect(tdate.lunarInfo, isNull); + expect(cell.selectType, DateSelectType.empty); + cell.typeNotifier.setType(DateSelectType.selected); + expect(cell.selectType, DateSelectType.selected); }); }); } -/// Mock 数据源用于测试 -class _MockDataSource extends TCalendarDataSource { - @override - TLunarInfo? getLunarInfo(DateTime solarDate) { - // 简单的 mock 实现 - return TLunarInfo( - year: 2025, - month: 3, - day: 7, - yearText: '二〇二五', - monthText: '三月', - dayText: '初七', - ); - } +class _MockDataSource extends TCalendarDataSource {} +class _SubtitleDataSource extends TCalendarDataSource { @override - String formatDate( - DateTime date, - TCalendarDateType type, [ - TLunarInfo? lunarInfo, - ]) { - if (type == TCalendarDateType.solar) { - return '${date.year}年${date.month}月${date.day}日'; - } else { - if (lunarInfo != null) { - return '${lunarInfo.yearText} ${lunarInfo.monthText}${lunarInfo.dayText}'; - } - return '${date.year}年${date.month}月${date.day}日'; - } - } + String? getSubtitle(DateTime date) => '初七'; } diff --git a/tdesign-component/test/t_calendar_test.dart b/tdesign-component/test/t_calendar_test.dart new file mode 100644 index 000000000..d763d579c --- /dev/null +++ b/tdesign-component/test/t_calendar_test.dart @@ -0,0 +1,693 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +Widget _buildTestApp(Widget child) { + return TTheme( + data: TThemeData.defaultData(), + child: MaterialApp(home: Scaffold(body: child)), + ); +} + +/// 把 [DateTime] 归一化到当天 00:00(与 TCalendar 内部 `_getValue` 行为一致), +/// 便于在断言中比较。测试 fixture 都使用 `DateTime(y, m, d)` 字面量构造, +/// 因此默认即为归一化值。 +DateTime _day(int y, int m, int d) => DateTime(y, m, d); + +void main() { + // ----------------------------------------------------------------------- + // popupOverlayBuilder / popupOverlayExpanded(仅 TCalendarInherited / showPopup) + // ----------------------------------------------------------------------- + group('TCalendar — popupOverlay / popupOverlayExpanded', () { + test('popupOverlayExpanded 未配合 popupOverlayBuilder 时触发 assert', () { + expect( + () => TCalendarInherited( + selected: ValueNotifier>([]), + popupOverlayExpanded: ValueNotifier(false), + child: const SizedBox(), + ), + throwsAssertionError, + ); + }); + + testWidgets('内嵌模式无法通过 TCalendar 传入 popupOverlayBuilder', (tester) async { + await tester.pumpWidget( + _buildTestApp( + TCalendar( + titleWidget: const Text('测试'), + height: 640, + ), + ), + ); + + expect(find.text('底部'), findsNothing); + }); + + testWidgets('非弹窗 Inherited 不渲染 popupOverlayBuilder', (tester) async { + await tester.pumpWidget( + _buildTestApp( + TCalendarInherited( + selected: ValueNotifier>([]), + usePopup: false, + popupOverlayBuilder: (_, __) => const Text('底部'), + child: TCalendar( + titleWidget: const Text('测试'), + height: 640, + ), + ), + ), + ); + + expect(find.text('底部'), findsNothing); + }); + + testWidgets('popup 内选中变化时 popupOverlayBuilder 会重建', (tester) async { + final day = _day(2024, 6, 15); + final selected = ValueNotifier>([day]); + + await tester.pumpWidget( + _buildTestApp( + TCalendarInherited( + selected: selected, + usePopup: true, + popupOverlayBuilder: (_, dates) => Text('days:${dates.length}'), + child: TCalendar( + titleWidget: const Text('测试'), + height: 640, + initialValue: selected.value, + ), + ), + ), + ); + + expect(find.text('days:1'), findsOneWidget); + + final day2 = day.add(const Duration(days: 1)); + selected.value = [day, _day(day2.year, day2.month, day2.day)]; + await tester.pump(); + + expect(find.text('days:2'), findsOneWidget); + }); + + testWidgets('popup 内 popupOverlayExpanded 为 false 时处于收起偏移', (tester) async { + final expanded = ValueNotifier(false); + final selected = ValueNotifier>([]); + + await tester.pumpWidget( + _buildTestApp( + TCalendarInherited( + selected: selected, + usePopup: true, + popupOverlayExpanded: expanded, + popupOverlayBuilder: (_, __) => const Text('底部内容'), + child: TCalendar( + titleWidget: const Text('测试'), + height: 640, + ), + ), + ), + ); + await tester.pump(); + + var slide = tester.widget( + find.descendant( + of: find.byType(TCalendar), + matching: find.byType(SlideTransition), + ), + ); + expect(slide.position.value.dy, 1.0); + + expanded.value = true; + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + + slide = tester.widget( + find.descendant( + of: find.byType(TCalendar), + matching: find.byType(SlideTransition), + ), + ); + expect(slide.position.value.dy, 0.0); + }); + }); + + // ----------------------------------------------------------------------- + // 单选 + // ----------------------------------------------------------------------- + group('TCalendar — 单选 (single)', () { + testWidgets('点击日期触发 onChange', (tester) async { + final day15 = _day(2024, 6, 15); + final day20 = _day(2024, 6, 20); + final minDate = _day(2024, 6, 1); + final maxDate = _day(2024, 6, 30); + List? result; + + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.single, + initialValue: [day15], + minDate: minDate, + maxDate: maxDate, + onChange: (v) => result = v, + ), + ), + ); + await tester.pumpAndSettle(); + + // 点击 20 号 + await tester.tap(find.text('20')); + await tester.pump(); + + expect(result, isNotNull); + expect(result!.length, 1); + expect(result!.first, day20); + }); + + testWidgets('点击已选中日期不重复触发 onChange', (tester) async { + final day15 = _day(2024, 6, 15); + final minDate = _day(2024, 6, 1); + final maxDate = _day(2024, 6, 30); + var callCount = 0; + + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.single, + initialValue: [day15], + minDate: minDate, + maxDate: maxDate, + onChange: (_) => callCount++, + ), + ), + ); + await tester.pumpAndSettle(); + + // 点击已选中的 15 号 + await tester.tap(find.text('15')); + await tester.pump(); + + expect(callCount, 0); + }); + + testWidgets('点击 disabled 日期不改变选中状态', (tester) async { + final day15 = _day(2024, 6, 15); + final minDate = _day(2024, 6, 10); + final maxDate = _day(2024, 6, 25); + List? result; + + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.single, + initialValue: [day15], + minDate: minDate, + maxDate: maxDate, + onChange: (v) => result = v, + ), + ), + ); + await tester.pumpAndSettle(); + + // 点击超出范围的 5 号(disabled) + final finder5 = find.text('5'); + if (finder5.evaluate().isNotEmpty) { + await tester.tap(finder5.first); + await tester.pump(); + } + + // onChange 不应被触发 + expect(result, isNull); + }); + }); + + // ----------------------------------------------------------------------- + // 多选 + // ----------------------------------------------------------------------- + group('TCalendar — 多选 (multiple)', () { + testWidgets('点击新日期添加选中', (tester) async { + final day15 = _day(2024, 6, 15); + final minDate = _day(2024, 6, 1); + final maxDate = _day(2024, 6, 30); + List? result; + + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.multiple, + initialValue: [day15], + minDate: minDate, + maxDate: maxDate, + onChange: (v) => result = v, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('20')); + await tester.pump(); + + expect(result, isNotNull); + expect(result!.length, 2); + expect(result!.contains(day15), isTrue); + expect(result!.contains(_day(2024, 6, 20)), isTrue); + }); + + testWidgets('再次点击已选日期取消选中', (tester) async { + final day15 = _day(2024, 6, 15); + final day20 = _day(2024, 6, 20); + final minDate = _day(2024, 6, 1); + final maxDate = _day(2024, 6, 30); + List? result; + + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.multiple, + initialValue: [day15, day20], + minDate: minDate, + maxDate: maxDate, + onChange: (v) => result = v, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('20')); + await tester.pump(); + + expect(result, isNotNull); + expect(result!.length, 1); + expect(result!.first, day15); + }); + }); + + // ----------------------------------------------------------------------- + // 区间选择 + // ----------------------------------------------------------------------- + group('TCalendar — 区间选择 (range)', () { + testWidgets('选择 start 和 end 触发 onChange', (tester) async { + final day15 = _day(2024, 6, 15); + final day20 = _day(2024, 6, 20); + final minDate = _day(2024, 6, 1); + final maxDate = _day(2024, 6, 30); + List? result; + + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.range, + minDate: minDate, + maxDate: maxDate, + onChange: (v) => result = v, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('15')); + await tester.pump(); + await tester.tap(find.text('20')); + await tester.pump(); + + expect(result, isNotNull); + expect(result!.length, 2); + expect(result![0], day15); + expect(result![1], day20); + }); + + testWidgets('end 在 start 之前时重置为新 start', (tester) async { + final day15 = _day(2024, 6, 15); + final day10 = _day(2024, 6, 10); + final minDate = _day(2024, 6, 1); + final maxDate = _day(2024, 6, 30); + List? result; + + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.range, + minDate: minDate, + maxDate: maxDate, + onChange: (v) => result = v, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('15')); + await tester.pump(); + await tester.tap(find.text('10')); + await tester.pump(); + + expect(result, isNotNull); + expect(result!.length, 1); + expect(result!.first, day10); + }); + }); + + // ----------------------------------------------------------------------- + // initialValue / anchorRevision + // ----------------------------------------------------------------------- + group('TCalendar — initialValue 与 anchorRevision', () { + testWidgets('运行期更新 initialValue 会同步选中 UI(single)', (tester) async { + final day15 = _day(2024, 6, 15); + final day10 = _day(2024, 6, 10); + final minDate = _day(2024, 6, 1); + final maxDate = _day(2024, 6, 30); + var selected = [day15]; + var onChangeCount = 0; + + Future pumpCalendar() { + return tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.single, + initialValue: List.from(selected), + minDate: minDate, + maxDate: maxDate, + onChange: (_) => onChangeCount++, + ), + ), + ); + } + + await pumpCalendar(); + await tester.pumpAndSettle(); + + await tester.tap(find.text('15')); + await tester.pump(); + expect(onChangeCount, 0); + + selected = [day10]; + await pumpCalendar(); + await tester.pumpAndSettle(); + + final countAfterReset = onChangeCount; + await tester.tap(find.text('10')); + await tester.pump(); + expect(onChangeCount, countAfterReset); + + await tester.tap(find.text('15')); + await tester.pump(); + expect(onChangeCount, countAfterReset + 1); + }); + + testWidgets('anchorRevision 递增可重复触发锚点滚动', (tester) async { + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + anchorDate: _day(2024, 6, 1), + anchorRevision: 0, + minDate: _day(2024, 1, 1), + maxDate: _day(2024, 12, 31), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + anchorDate: _day(2024, 6, 1), + anchorRevision: 1, + minDate: _day(2024, 1, 1), + maxDate: _day(2024, 12, 31), + ), + ), + ); + await tester.pumpAndSettle(); + }); + }); + + // ----------------------------------------------------------------------- + // 边界条件 + // ----------------------------------------------------------------------- + group('TCalendar — 边界条件', () { + testWidgets('不传 initialValue 时正常渲染', (tester) async { + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.single, + minDate: _day(2024, 6, 1), + maxDate: _day(2024, 6, 30), + ), + ), + ); + await tester.pumpAndSettle(); + + // 应能看到日期 + expect(find.text('1'), findsWidgets); + expect(find.text('30'), findsOneWidget); + }); + + testWidgets('空 initialValue 列表正常渲染', (tester) async { + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.single, + initialValue: const [], + minDate: _day(2024, 6, 1), + maxDate: _day(2024, 6, 30), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('15'), findsOneWidget); + }); + + testWidgets('onCellClick 回调携带正确参数', (tester) async { + final minDate = _day(2024, 6, 1); + final maxDate = _day(2024, 6, 30); + DateTime? clickedValue; + DateSelectType? clickedType; + + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.single, + minDate: minDate, + maxDate: maxDate, + onCellClick: (value, type, tdate) { + clickedValue = value; + clickedType = type; + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('15')); + await tester.pump(); + + expect(clickedValue, _day(2024, 6, 15)); + expect(clickedType, DateSelectType.selected); + }); + + testWidgets('单月范围(minDate == maxDate 同月)正常渲染', (tester) async { + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.single, + minDate: _day(2024, 6, 1), + maxDate: _day(2024, 6, 1), + ), + ), + ); + await tester.pumpAndSettle(); + + // 只有 1 号可选,其余 disabled + expect(find.text('1'), findsWidgets); + }); + + testWidgets('firstDayOfWeek = 1(周一开始)正常渲染', (tester) async { + await tester.pumpWidget( + _buildTestApp( + TCalendar( + height: 640, + type: CalendarType.single, + firstDayOfWeek: 1, + minDate: _day(2024, 6, 1), + maxDate: _day(2024, 6, 30), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('15'), findsOneWidget); + }); + }); + + // ----------------------------------------------------------------------- + // showPopup 集成 + // ----------------------------------------------------------------------- + group('TCalendar.showPopup', () { + testWidgets('选中新日期并确认后返回选中列表', (tester) async { + final day15 = _day(2024, 6, 15); + final day20 = _day(2024, 6, 20); + List? popupResult; + + await tester.pumpWidget( + _buildTestApp( + Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + popupResult = await TCalendar.showPopup( + context, + titleWidget: const Text('选择日期'), + type: CalendarType.single, + initialValue: [day15], + minDate: _day(2024, 6, 1), + maxDate: _day(2024, 6, 30), + ); + }, + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('选择日期'), findsOneWidget); + + await tester.tap(find.text('20')); + await tester.pump(); + + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + + expect(popupResult, isNotNull); + expect(popupResult!.length, 1); + expect(popupResult!.first, day20); + }); + + testWidgets('点击关闭按钮未确认时返回 null', (tester) async { + List? popupResult; + + await tester.pumpWidget( + _buildTestApp( + Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + popupResult = await TCalendar.showPopup( + context, + titleWidget: const Text('选择日期'), + type: CalendarType.single, + minDate: _day(2024, 6, 1), + maxDate: _day(2024, 6, 30), + ); + }, + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect(popupResult, isNull); + }); + + testWidgets('自定义 confirmBtnBuilder 点击后返回选中值并关闭弹窗', (tester) async { + final day15 = _day(2024, 6, 15); + final day20 = _day(2024, 6, 20); + List? popupResult; + + await tester.pumpWidget( + _buildTestApp( + Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + popupResult = await TCalendar.showPopup( + context, + titleWidget: const Text('选择日期'), + type: CalendarType.single, + initialValue: [day15], + minDate: _day(2024, 6, 1), + maxDate: _day(2024, 6, 30), + confirmBtnBuilder: (onConfirm) => Padding( + padding: const EdgeInsets.all(16), + child: GestureDetector( + onTap: onConfirm, + child: const Text('ok'), + ), + ), + ); + }, + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('20')); + await tester.pump(); + + await tester.tap(find.text('ok')); + await tester.pumpAndSettle(); + + expect(popupResult, isNotNull); + expect(popupResult!.single, day20); + }); + + testWidgets('popupOverlayBuilder 与确认按钮区域不重叠', (tester) async { + await tester.pumpWidget( + _buildTestApp( + Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + await TCalendar.showPopup( + context, + titleWidget: const Text('选择日期'), + type: CalendarType.single, + minDate: _day(2024, 6, 1), + maxDate: _day(2024, 6, 30), + popupHeight: 640, + popupOverlayBuilder: (_, __) => const Text('底部区域'), + ); + }, + child: const Text('open'), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + final positioned = tester + .widgetList( + find.ancestor( + of: find.text('底部区域'), + matching: find.byType(Positioned), + ), + ) + .firstWhere((p) => (p.bottom ?? 0) > 0); + expect(positioned.bottom, greaterThan(0)); + }); + }); +} diff --git a/tdesign-site/src/_common b/tdesign-site/src/_common index 1c930580b..0ff86bbc6 160000 --- a/tdesign-site/src/_common +++ b/tdesign-site/src/_common @@ -1 +1 @@ -Subproject commit 1c930580be92a98e431ec3ef76af0b7d98c529d6 +Subproject commit 0ff86bbc6d1c9c5e8094a4e5b407809b8d928e77 diff --git a/tdesign-site/src/calendar/README.md b/tdesign-site/src/calendar/README.md index 060def6ed..1cd5b5826 100644 --- a/tdesign-site/src/calendar/README.md +++ b/tdesign-site/src/calendar/README.md @@ -27,205 +27,192 @@ import 'package:tdesign_flutter/tdesign_flutter.dart';
 Widget _buildSimple(BuildContext context) {
-  final size = MediaQuery.of(context).size;
-  final selected = ValueNotifier>(
-      [DateTime.now().millisecondsSinceEpoch + 30 * 24 * 60 * 60 * 1000]);
+  return const _SimpleDemo();
+}
+ + + +### 1 组件样式 + +自定义文案、按钮、单元格 + + + + +
+Widget _buildStyle(BuildContext context) {
+  const map = {
+    1: '初一',
+    2: '初二',
+    3: '初三',
+    14: '情人节',
+    15: '元宵节',
+  };
+
+  final customTextSelected =
+      ValueNotifier>([DateTime(2022, 1, 15)]);
+  final customBtnSelected =
+      ValueNotifier>([DateTime.now()]);
+  final customCellSelected = ValueNotifier>(
+      [DateTime.now().add(const Duration(days: 30))]);
+
   return ValueListenableBuilder(
-    valueListenable: selected,
-    builder: (context, value, child) {
-      final date = DateTime.fromMillisecondsSinceEpoch(value[0]);
-      return TCellGroup(
-        cells: [
+    valueListenable: customTextSelected,
+    builder: (context, textSelected, _) {
+      return ValueListenableBuilder(
+        valueListenable: customBtnSelected,
+        builder: (context, btnSelected, _) {
+          return ValueListenableBuilder(
+            valueListenable: customCellSelected,
+            builder: (context, cellValue, _) {
+              final cellDate = cellValue[0];
+              return TCellGroup(
+                cells: [
+          // 1. 自定义文案(cellBuilder,仅 showPopup 弹窗模式)
           TCell(
-            title: '单个选择日历',
+            title: '自定义文案',
             arrow: true,
-            note: '${date.year}-${date.month}-${date.day}',
-            onClick: (cell) {
-              TCalendarPopup(
+            note: _formatYmd(textSelected),
+            onClick: (_) {
+              TCalendar.showPopup(
                 context,
-                visible: true,
-                onConfirm: (value) {
-                  print('onConfirm:$value');
-                  selected.value = value;
-                },
-                onClose: () {
-                  print('onClose');
+                titleWidget: const Text('请选择日期'),
+                initialValue: textSelected,
+                minDate: DateTime(2022, 1, 1),
+                maxDate: DateTime(2022, 2, 15),
+                onConfirm: (value) => customTextSelected.value = value,
+                cellBuilder: (context, cell) {
+                  final isSpecial = cell.date.month == 2 &&
+                      map.keys.contains(cell.date.day);
+                  final sub = isSpecial ? '¥100' : '¥60';
+                  final top = isSpecial ? map[cell.date.day] : null;
+                  return Column(
+                    mainAxisAlignment: MainAxisAlignment.center,
+                    children: [
+                      if (top != null)
+                        Text(top,
+                            style: TextStyle(
+                              fontSize: 9,
+                              color: isSpecial
+                                  ? TTheme.of(context).errorColor6
+                                  : null,
+                            )),
+                      Text(
+                        cell.date.day.toString(),
+                        style: TextStyle(
+                          color: cell.selectType == DateSelectType.selected
+                              ? TTheme.of(context).fontWhColor1
+                              : isSpecial
+                                  ? TTheme.of(context).errorColor6
+                                  : null,
+                        ),
+                      ),
+                      Text(sub,
+                          style: TextStyle(
+                            fontSize: 9,
+                            color: cell.selectType == DateSelectType.selected
+                                ? TTheme.of(context).fontWhColor1
+                                : isSpecial
+                                    ? TTheme.of(context).errorColor6
+                                    : null,
+                          )),
+                    ],
+                  );
                 },
-                child: TCalendar(
-                  title: '请选择日期',
-                  value: value,
-                  height: size.height * 0.6 + 176,
-                  onCellClick: (value, type, tdate) {
-                    print('onCellClick: $value');
-                  },
-                  onCellLongPress: (value, type, tdate) {
-                    print('onCellLongPress: $value');
-                  },
-                  onHeaderClick: (index, week) {
-                    print('onHeaderClick: $week');
-                  },
-                  onChange: (value) {
-                    print('onChange: $value');
-                  },
-                ),
-              );
-            },
-          ),
-          TCell(
-            title: '多个选择日历',
-            arrow: true,
-            onClick: (cell) {
-              TCalendarPopup(
-                context,
-                visible: true,
-                child: TCalendar(
-                  title: '请选择日期',
-                  type: CalendarType.multiple,
-                  value: [DateTime.now().millisecondsSinceEpoch],
-                  height: size.height * 0.6 + 176,
-                ),
               );
             },
           ),
+
+          // 2. 自定义确认按钮
           TCell(
-            title: '区间选择日历',
+            title: '自定义按钮',
             arrow: true,
-            onClick: (cell) {
-              TCalendarPopup(
+            note: _formatYmd(btnSelected),
+            onClick: (_) {
+              TCalendar.showPopup(
                 context,
-                visible: true,
-                child: TCalendar(
-                  title: '请选择日期区间',
-                  type: CalendarType.range,
-                  value: [
-                    DateTime.now().millisecondsSinceEpoch,
-                    DateTime.now()
-                        .add(const Duration(days: 6))
-                        .millisecondsSinceEpoch,
-                  ],
-                  height: size.height * 0.6 + 176,
-                ),
-              );
-            },
-          ),
-          TCell(
-            title: '单个选择日历和时间',
-            arrow: true,
-            note:
-                '${date.year}-${date.month}-${date.day} ${date.hour}:${date.minute}',
-            onClick: (cell) {
-              TCalendarPopup(
-                context,
-                visible: true,
-                onConfirm: (value) {
-                  print('onConfirm:$value');
-                  selected.value = value;
-                },
-                onClose: () {
-                  print('onClose');
-                },
-                child: TCalendar(
-                  title: '请选择日期和时间',
-                  value: value,
-                  height: size.height * 0.92,
-                  useTimePicker: true,
-                  // pickerHeight: 100,
-                  // pickerItemCount: 2,
-                  onCellClick: (value, type, tdate) {
-                    print('onCellClick: $value');
-                  },
-                  onCellLongPress: (value, type, tdate) {
-                    print('onCellLongPress: $value');
-                  },
-                  onHeaderClick: (index, week) {
-                    print('onHeaderClick: $week');
-                  },
-                  onChange: (value) {
-                    print('onChange: $value');
-                  },
+                titleWidget: const Text('请选择日期'),
+                initialValue: btnSelected,
+                confirmBtnBuilder: (onConfirm) => Padding(
+                  padding: EdgeInsets.symmetric(
+                      vertical: TTheme.of(context).spacer16),
+                  child: TButton(
+                    theme: TButtonTheme.danger,
+                    shape: TButtonShape.round,
+                    text: 'ok',
+                    isBlock: true,
+                    size: TButtonSize.large,
+                    onTap: onConfirm,
+                  ),
                 ),
+                onConfirm: (value) => customBtnSelected.value = value,
               );
             },
           ),
+
+          // 3. 自定义日期单元格(cellBuilder 回调)
           TCell(
-            title: '区间选择日历和时间',
+            title: '自定义日期单元格',
             arrow: true,
+            note: '${cellDate.year}-${cellDate.month}-${cellDate.day}',
             onClick: (cell) {
-              TCalendarPopup(
+              TCalendar.showPopup(
                 context,
-                visible: true,
-                onConfirm: (value) {
-                  print('onConfirm: $value');
-                },
-                onClose: () {
-                  print('onClose');
+                titleWidget: const Text('请选择日期'),
+                initialValue: cellValue,
+                cellHeight: 80,
+                onConfirm: (value) => customCellSelected.value = value,
+                cellBuilder: (context, cell) {
+                  final today = DateTime.now();
+                  final isToday = cell.date ==
+                      DateTime(today.year, today.month, today.day);
+
+                  if (isToday && cell.selectType != DateSelectType.selected) {
+                    return _CustomCellContainer(
+                      color: TTheme.of(context).brandColor4,
+                      child: const Text('今天',
+                          style: TextStyle(
+                              fontSize: 18,
+                              fontWeight: FontWeight.bold,
+                              color: Colors.white)),
+                    );
+                  }
+                  if (cell.selectType == DateSelectType.selected) {
+                    return _CustomCellContainer(
+                      color: TTheme.of(context).successColor8,
+                      child: Column(
+                        mainAxisAlignment: MainAxisAlignment.center,
+                        children: [
+                          Text('${cell.date.day}',
+                              style: const TextStyle(
+                                  fontSize: 18,
+                                  fontWeight: FontWeight.bold,
+                                  color: Colors.white)),
+                          const Text('已选',
+                              style:
+                                  TextStyle(fontSize: 10, color: Colors.white)),
+                        ],
+                      ),
+                    );
+                  }
+                  return Column(
+                    mainAxisAlignment: MainAxisAlignment.center,
+                    children: [
+                      Text('${cell.date.day}',
+                          style: const TextStyle(
+                              fontSize: 18, fontWeight: FontWeight.bold)),
+                      const Text('自定义', style: TextStyle(fontSize: 8)),
+                    ],
+                  );
                 },
-                child: TCalendar(
-                  title: '请选择日期和时间区间',
-                  height: size.height * 0.92,
-                  type: CalendarType.range,
-                  value: [
-                    DateTime.now().millisecondsSinceEpoch,
-                    DateTime.now()
-                        .add(const Duration(days: 3))
-                        .millisecondsSinceEpoch,
-                  ],
-                  useTimePicker: true,
-                  onCellClick: (value, type, tdate) {
-                    print('onCellClick: $value');
-                  },
-                  onCellLongPress: (value, type, tdate) {
-                    print('onCellLongPress: $value');
-                  },
-                  onHeaderClick: (index, week) {
-                    print('onHeaderClick: $week');
-                  },
-                  onChange: (value) {
-                    print('onChange: $value');
-                  },
-                ),
               );
             },
           ),
-          TCell(
-            title: '添加锚点',
-            arrow: true,
-            note: '${date.year}-${date.month}-${date.day}',
-            onClick: (cell) {
-              TCalendarPopup(
-                context,
-                visible: true,
-                onConfirm: (value) {
-                  print('onConfirm:$value');
-                  selected.value = value;
-                },
-                onClose: () {
-                  print('onClose');
-                },
-                child: TCalendar(
-                  title: '请选择日期',
-                  minDate: DateTime(2022, 1, 1).millisecondsSinceEpoch,
-                  maxDate: DateTime(2028, 2, 15).millisecondsSinceEpoch,
-                  anchorDate: DateTime(2026, 5),
-                  value: value,
-                  height: size.height * 0.6 + 176,
-                  onCellClick: (value, type, tdate) {
-                    print('onCellClick: $value');
-                  },
-                  onCellLongPress: (value, type, tdate) {
-                    print('onCellLongPress: $value');
-                  },
-                  onHeaderClick: (index, week) {
-                    print('onHeaderClick: $week');
-                  },
-                  onChange: (value) {
-                    print('onChange: $value');
-                  },
-                ),
+                ],
               );
             },
-          ),
-        ],
+          );
+        },
       );
     },
   );
@@ -233,16 +220,12 @@ Widget _buildSimple(BuildContext context) {
 
 
                 
-### 1 组件样式
-
-可以自由定义想要的风格
 
           
 
 
   
 Widget _buildStyle(BuildContext context) {
-  final size = MediaQuery.of(context).size;
   const map = {
     1: '初一',
     2: '初二',
@@ -250,569 +233,173 @@ Widget _buildStyle(BuildContext context) {
     14: '情人节',
     15: '元宵节',
   };
-  return TCellGroup(
-    cells: [
-      TCell(
-        title: '自定义文案',
-        arrow: true,
-        onClick: (cell) {
-          TCalendarPopup(
-            context,
-            visible: true,
-            child: TCalendar(
-              title: '请选择日期',
-              height: size.height * 0.6 + 176,
-              minDate: DateTime(2022, 1, 1).millisecondsSinceEpoch,
-              maxDate: DateTime(2022, 2, 15).millisecondsSinceEpoch,
-              format: (day) {
-                day?.suffix = '¥60';
-                if (day?.date.month == 2) {
-                  if (map.keys.contains(day?.date.day)) {
-                    day?.suffix = '¥100';
-                    day?.prefix = map[day.date.day];
-                    day?.style = TextStyle(
-                      fontSize: TTheme.of(context).fontTitleMedium?.size,
-                      height: TTheme.of(context).fontTitleMedium?.height,
-                      fontWeight:
-                          TTheme.of(context).fontTitleMedium?.fontWeight,
-                      color: TTheme.of(context).errorColor6,
-                    );
-                    if (day?.typeNotifier.value == DateSelectType.selected) {
-                      day?.style = day.style
-                          ?.copyWith(color: TTheme.of(context).fontWhColor1);
-                    }
-                  }
-                }
-                return null;
-              },
-            ),
-          );
-        },
-      ),
-      TCell(
-        title: '自定义按钮',
-        arrow: true,
-        onClick: (cell) {
-          late final TCalendarPopup calendar;
-          calendar = TCalendarPopup(
-            context,
-            visible: true,
-            confirmBtn: Padding(
-              padding:
-                  EdgeInsets.symmetric(vertical: TTheme.of(context).spacer16),
-              child: TButton(
-                theme: TButtonTheme.danger,
-                shape: TButtonShape.round,
-                text: 'ok',
-                isBlock: true,
-                size: TButtonSize.large,
-                onTap: () {
-                  print(calendar.selected);
-                  calendar.close();
-                },
-              ),
-            ),
-            child: TCalendar(
-              title: '请选择日期',
-              value: [DateTime.now().millisecondsSinceEpoch],
-              height: size.height * 0.6 + 176,
-            ),
-          );
-        },
-      ),
-      TCell(
-        title: '自定义日期区间',
-        arrow: true,
-        onClick: (cell) {
-          TCalendarPopup(
-            context,
-            visible: true,
-            child: TCalendar(
-              title: '请选择日期',
-              minDate: DateTime(2000, 1, 1).millisecondsSinceEpoch,
-              maxDate: DateTime(3000, 1, 1).millisecondsSinceEpoch,
-              value: [DateTime(2024, 10, 1).millisecondsSinceEpoch],
-              height: size.height * 0.6 + 176,
-            ),
-          );
-        },
-      ),
-    ],
-  );
-}
-
- + final customTextSelected = + ValueNotifier>([DateTime(2022, 1, 15)]); + final customBtnSelected = + ValueNotifier>([DateTime.now()]); + final customCellSelected = ValueNotifier>( + [DateTime.now().add(const Duration(days: 30))]); - - - -
-Widget _buildStyle(BuildContext context) {
-  final size = MediaQuery.of(context).size;
-  const map = {
-    1: '初一',
-    2: '初二',
-    3: '初三',
-    14: '情人节',
-    15: '元宵节',
-  };
-  return TCellGroup(
-    cells: [
-      TCell(
-        title: '自定义文案',
-        arrow: true,
-        onClick: (cell) {
-          TCalendarPopup(
-            context,
-            visible: true,
-            child: TCalendar(
-              title: '请选择日期',
-              height: size.height * 0.6 + 176,
-              minDate: DateTime(2022, 1, 1).millisecondsSinceEpoch,
-              maxDate: DateTime(2022, 2, 15).millisecondsSinceEpoch,
-              format: (day) {
-                day?.suffix = '¥60';
-                if (day?.date.month == 2) {
-                  if (map.keys.contains(day?.date.day)) {
-                    day?.suffix = '¥100';
-                    day?.prefix = map[day.date.day];
-                    day?.style = TextStyle(
-                      fontSize: TTheme.of(context).fontTitleMedium?.size,
-                      height: TTheme.of(context).fontTitleMedium?.height,
-                      fontWeight:
-                          TTheme.of(context).fontTitleMedium?.fontWeight,
-                      color: TTheme.of(context).errorColor6,
-                    );
-                    if (day?.typeNotifier.value == DateSelectType.selected) {
-                      day?.style = day.style
-                          ?.copyWith(color: TTheme.of(context).fontWhColor1);
-                    }
-                  }
-                }
-                return null;
-              },
-            ),
-          );
-        },
-      ),
-      TCell(
-        title: '自定义按钮',
-        arrow: true,
-        onClick: (cell) {
-          late final TCalendarPopup calendar;
-          calendar = TCalendarPopup(
-            context,
-            visible: true,
-            confirmBtn: Padding(
-              padding:
-                  EdgeInsets.symmetric(vertical: TTheme.of(context).spacer16),
-              child: TButton(
-                theme: TButtonTheme.danger,
-                shape: TButtonShape.round,
-                text: 'ok',
-                isBlock: true,
-                size: TButtonSize.large,
-                onTap: () {
-                  print(calendar.selected);
-                  calendar.close();
-                },
-              ),
-            ),
-            child: TCalendar(
-              title: '请选择日期',
-              value: [DateTime.now().millisecondsSinceEpoch],
-              height: size.height * 0.6 + 176,
-            ),
-          );
-        },
-      ),
-      TCell(
-        title: '自定义日期区间',
-        arrow: true,
-        onClick: (cell) {
-          TCalendarPopup(
-            context,
-            visible: true,
-            child: TCalendar(
-              title: '请选择日期',
-              minDate: DateTime(2000, 1, 1).millisecondsSinceEpoch,
-              maxDate: DateTime(3000, 1, 1).millisecondsSinceEpoch,
-              value: [DateTime(2024, 10, 1).millisecondsSinceEpoch],
-              height: size.height * 0.6 + 176,
-            ),
-          );
-        },
-      ),
-    ],
-  );
-}
- -
- - -自定义日期单元格 - - - - -
-Widget _buildCustomCell(BuildContext context) {
-  final size = MediaQuery.of(context).size;
-  final selected = ValueNotifier>(
-      [DateTime.now().millisecondsSinceEpoch + 30 * 24 * 60 * 60 * 1000]);
   return ValueListenableBuilder(
-    valueListenable: selected,
-    builder: (context, value, child) {
-      final date = DateTime.fromMillisecondsSinceEpoch(value[0]);
-      return TCellGroup(
-        cells: [
+    valueListenable: customTextSelected,
+    builder: (context, textSelected, _) {
+      return ValueListenableBuilder(
+        valueListenable: customBtnSelected,
+        builder: (context, btnSelected, _) {
+          return ValueListenableBuilder(
+            valueListenable: customCellSelected,
+            builder: (context, cellValue, _) {
+              final cellDate = cellValue[0];
+              return TCellGroup(
+                cells: [
+          // 1. 自定义文案(cellBuilder,仅 showPopup 弹窗模式)
           TCell(
-            title: '自定义日期单元格',
+            title: '自定义文案',
             arrow: true,
-            note: '${date.year}-${date.month}-${date.day}',
-            onClick: (cell) {
-              TCalendarPopup(
+            note: _formatYmd(textSelected),
+            onClick: (_) {
+              TCalendar.showPopup(
                 context,
-                visible: true,
-                onConfirm: (value) {
-                  print('onConfirm:$value');
-                  selected.value = value;
-                },
-                onClose: () {
-                  print('onClose');
+                titleWidget: const Text('请选择日期'),
+                initialValue: textSelected,
+                minDate: DateTime(2022, 1, 1),
+                maxDate: DateTime(2022, 2, 15),
+                onConfirm: (value) => customTextSelected.value = value,
+                cellBuilder: (context, cell) {
+                  final isSpecial = cell.date.month == 2 &&
+                      map.keys.contains(cell.date.day);
+                  final sub = isSpecial ? '¥100' : '¥60';
+                  final top = isSpecial ? map[cell.date.day] : null;
+                  return Column(
+                    mainAxisAlignment: MainAxisAlignment.center,
+                    children: [
+                      if (top != null)
+                        Text(top,
+                            style: TextStyle(
+                              fontSize: 9,
+                              color: isSpecial
+                                  ? TTheme.of(context).errorColor6
+                                  : null,
+                            )),
+                      Text(
+                        cell.date.day.toString(),
+                        style: TextStyle(
+                          color: cell.selectType == DateSelectType.selected
+                              ? TTheme.of(context).fontWhColor1
+                              : isSpecial
+                                  ? TTheme.of(context).errorColor6
+                                  : null,
+                        ),
+                      ),
+                      Text(sub,
+                          style: TextStyle(
+                            fontSize: 9,
+                            color: cell.selectType == DateSelectType.selected
+                                ? TTheme.of(context).fontWhColor1
+                                : isSpecial
+                                    ? TTheme.of(context).errorColor6
+                                    : null,
+                          )),
+                    ],
+                  );
                 },
-                child: TCalendar(
-                    title: '请选择日期',
-                    value: value,
-                    cellHeight: 80,
-                    height: size.height * 0.6 + 176,
-                    onCellClick: (value, type, tdate) {
-                      print('onCellClick: $value');
-                    },
-                    onCellLongPress: (value, type, tdate) {
-                      print('onCellLongPress: $value');
-                    },
-                    onHeaderClick: (index, week) {
-                      print('onHeaderClick: $week');
-                    },
-                    onChange: (value) {
-                      print('onChange: $value');
-                    },
-                    cellWidget: (context, tdate, selectType) {
-                      final today = DateTime.now();
-                      //当前日期的自定义实现
-                      if (tdate.date.millisecondsSinceEpoch ==
-                              DateTime(today.year, today.month, today.day)
-                                  .millisecondsSinceEpoch &&
-                          selectType != DateSelectType.selected) {
-                        return Container(
-                          decoration: BoxDecoration(
-                            color: TTheme.of(context).brandColor4,
-                            borderRadius: BorderRadius.all(Radius.circular(6)),
-                          ),
-                          constraints: const BoxConstraints(
-                              minWidth: 0, // 最小宽度为0
-                              maxWidth: double.infinity, // 最大宽度无限
-                              minHeight: 0, // 最小高度为0
-                              maxHeight: double.infinity),
-                          child: const Column(
-                            mainAxisAlignment: MainAxisAlignment.center,
-                            children: [
-                              Text('今天',
-                                  style: TextStyle(
-                                      fontSize: 18,
-                                      fontWeight: FontWeight.bold,
-                                      color: Colors.white)),
-                            ],
-                          ),
-                        );
-                      }
-                      if (selectType == DateSelectType.selected) {
-                        return Container(
-                          decoration: BoxDecoration(
-                            color: TTheme.of(context).successColor8,
-                            borderRadius: BorderRadius.all(Radius.circular(6)),
-                          ),
-                          constraints: const BoxConstraints(
-                              minWidth: 0, // 最小宽度为0
-                              maxWidth: double.infinity, // 最大宽度无限
-                              minHeight: 0, // 最小高度为0
-                              maxHeight: double.infinity),
-                          child: Column(
-                            mainAxisAlignment: MainAxisAlignment.center,
-                            children: [
-                              Text('${tdate.date.day}',
-                                  style: const TextStyle(
-                                      fontSize: 18,
-                                      fontWeight: FontWeight.bold,
-                                      color: Colors.white)),
-                              const Text('文案文案',
-                                  style: TextStyle(
-                                      fontSize: 6, color: Colors.white)),
-                              const Text('自定义',
-                                  style: TextStyle(
-                                      fontSize: 12, color: Colors.white)),
-                            ],
-                          ),
-                        );
-                      }
-                      return Column(
-                        mainAxisAlignment: MainAxisAlignment.center,
-                        children: [
-                          Text('${tdate.date.day}',
-                              style: const TextStyle(
-                                  fontSize: 18, fontWeight: FontWeight.bold)),
-                          const Text('文案文案', style: TextStyle(fontSize: 8)),
-                          const Text('自定义', style: TextStyle(fontSize: 8)),
-                        ],
-                      );
-                    }),
               );
             },
           ),
-        ],
-      );
-    },
-  );
-}
- -
- - - + // 2. 自定义确认按钮 + TCell( + title: '自定义按钮', + arrow: true, + note: _formatYmd(btnSelected), + onClick: (_) { + TCalendar.showPopup( + context, + titleWidget: const Text('请选择日期'), + initialValue: btnSelected, + confirmBtnBuilder: (onConfirm) => Padding( + padding: EdgeInsets.symmetric( + vertical: TTheme.of(context).spacer16), + child: TButton( + theme: TButtonTheme.danger, + shape: TButtonShape.round, + text: 'ok', + isBlock: true, + size: TButtonSize.large, + onTap: onConfirm, + ), + ), + onConfirm: (value) => customBtnSelected.value = value, + ); + }, + ), -
-Widget _buildCustomCell(BuildContext context) {
-  final size = MediaQuery.of(context).size;
-  final selected = ValueNotifier>(
-      [DateTime.now().millisecondsSinceEpoch + 30 * 24 * 60 * 60 * 1000]);
-  return ValueListenableBuilder(
-    valueListenable: selected,
-    builder: (context, value, child) {
-      final date = DateTime.fromMillisecondsSinceEpoch(value[0]);
-      return TCellGroup(
-        cells: [
+          // 3. 自定义日期单元格(cellBuilder 回调)
           TCell(
             title: '自定义日期单元格',
             arrow: true,
-            note: '${date.year}-${date.month}-${date.day}',
+            note: '${cellDate.year}-${cellDate.month}-${cellDate.day}',
             onClick: (cell) {
-              TCalendarPopup(
+              TCalendar.showPopup(
                 context,
-                visible: true,
-                onConfirm: (value) {
-                  print('onConfirm:$value');
-                  selected.value = value;
-                },
-                onClose: () {
-                  print('onClose');
-                },
-                child: TCalendar(
-                    title: '请选择日期',
-                    value: value,
-                    cellHeight: 80,
-                    height: size.height * 0.6 + 176,
-                    onCellClick: (value, type, tdate) {
-                      print('onCellClick: $value');
-                    },
-                    onCellLongPress: (value, type, tdate) {
-                      print('onCellLongPress: $value');
-                    },
-                    onHeaderClick: (index, week) {
-                      print('onHeaderClick: $week');
-                    },
-                    onChange: (value) {
-                      print('onChange: $value');
-                    },
-                    cellWidget: (context, tdate, selectType) {
-                      final today = DateTime.now();
-                      //当前日期的自定义实现
-                      if (tdate.date.millisecondsSinceEpoch ==
-                              DateTime(today.year, today.month, today.day)
-                                  .millisecondsSinceEpoch &&
-                          selectType != DateSelectType.selected) {
-                        return Container(
-                          decoration: BoxDecoration(
-                            color: TTheme.of(context).brandColor4,
-                            borderRadius: BorderRadius.all(Radius.circular(6)),
-                          ),
-                          constraints: const BoxConstraints(
-                              minWidth: 0, // 最小宽度为0
-                              maxWidth: double.infinity, // 最大宽度无限
-                              minHeight: 0, // 最小高度为0
-                              maxHeight: double.infinity),
-                          child: const Column(
-                            mainAxisAlignment: MainAxisAlignment.center,
-                            children: [
-                              Text('今天',
-                                  style: TextStyle(
-                                      fontSize: 18,
-                                      fontWeight: FontWeight.bold,
-                                      color: Colors.white)),
-                            ],
-                          ),
-                        );
-                      }
-                      if (selectType == DateSelectType.selected) {
-                        return Container(
-                          decoration: BoxDecoration(
-                            color: TTheme.of(context).successColor8,
-                            borderRadius: BorderRadius.all(Radius.circular(6)),
-                          ),
-                          constraints: const BoxConstraints(
-                              minWidth: 0, // 最小宽度为0
-                              maxWidth: double.infinity, // 最大宽度无限
-                              minHeight: 0, // 最小高度为0
-                              maxHeight: double.infinity),
-                          child: Column(
-                            mainAxisAlignment: MainAxisAlignment.center,
-                            children: [
-                              Text('${tdate.date.day}',
-                                  style: const TextStyle(
-                                      fontSize: 18,
-                                      fontWeight: FontWeight.bold,
-                                      color: Colors.white)),
-                              const Text('文案文案',
-                                  style: TextStyle(
-                                      fontSize: 6, color: Colors.white)),
-                              const Text('自定义',
-                                  style: TextStyle(
-                                      fontSize: 12, color: Colors.white)),
-                            ],
-                          ),
-                        );
-                      }
-                      return Column(
+                titleWidget: const Text('请选择日期'),
+                initialValue: cellValue,
+                cellHeight: 80,
+                onConfirm: (value) => customCellSelected.value = value,
+                cellBuilder: (context, cell) {
+                  final today = DateTime.now();
+                  final isToday = cell.date ==
+                      DateTime(today.year, today.month, today.day);
+
+                  if (isToday && cell.selectType != DateSelectType.selected) {
+                    return _CustomCellContainer(
+                      color: TTheme.of(context).brandColor4,
+                      child: const Text('今天',
+                          style: TextStyle(
+                              fontSize: 18,
+                              fontWeight: FontWeight.bold,
+                              color: Colors.white)),
+                    );
+                  }
+                  if (cell.selectType == DateSelectType.selected) {
+                    return _CustomCellContainer(
+                      color: TTheme.of(context).successColor8,
+                      child: Column(
                         mainAxisAlignment: MainAxisAlignment.center,
                         children: [
-                          Text('${tdate.date.day}',
+                          Text('${cell.date.day}',
                               style: const TextStyle(
-                                  fontSize: 18, fontWeight: FontWeight.bold)),
-                          const Text('文案文案', style: TextStyle(fontSize: 8)),
-                          const Text('自定义', style: TextStyle(fontSize: 8)),
+                                  fontSize: 18,
+                                  fontWeight: FontWeight.bold,
+                                  color: Colors.white)),
+                          const Text('已选',
+                              style:
+                                  TextStyle(fontSize: 10, color: Colors.white)),
                         ],
-                      );
-                    }),
+                      ),
+                    );
+                  }
+                  return Column(
+                    mainAxisAlignment: MainAxisAlignment.center,
+                    children: [
+                      Text('${cell.date.day}',
+                          style: const TextStyle(
+                              fontSize: 18, fontWeight: FontWeight.bold)),
+                      const Text('自定义', style: TextStyle(fontSize: 8)),
+                    ],
+                  );
+                },
               );
             },
           ),
-        ],
-      );
-    },
-  );
-}
- -
- - -不使用Popup - - - - -
-Widget _buildBlock(BuildContext context) {
-  final size = MediaQuery.of(context).size;
-  final selected = ValueNotifier>(
-    [DateTime.now().millisecondsSinceEpoch + 30 * 24 * 60 * 60 * 1000],
-  );
-  return Column(
-    // spacing: TTheme.of(context).spacer16,
-    crossAxisAlignment: CrossAxisAlignment.start,
-    children: [
-      Row(
-        // spacing: TTheme.of(context).spacer16,
-        mainAxisAlignment: MainAxisAlignment.center,
-        children: [
-          TButton(
-            text: '加一个月',
-            theme: TButtonTheme.primary,
-            onTap: () {
-              selected.value = [selected.value[0] + 30 * 24 * 60 * 60 * 1000];
-            },
-          ),
-          const SizedBox(width: 16),
-          TButton(
-            text: '减一个月',
-            theme: TButtonTheme.primary,
-            onTap: () {
-              selected.value = [selected.value[0] - 30 * 24 * 60 * 60 * 1000];
-            },
-          ),
-        ],
-      ),
-      const SizedBox(height: 16),
-      ValueListenableBuilder(
-        valueListenable: selected,
-        builder: (context, value, child) {
-          return TCalendar(
-            title: '请选择日期',
-            value: value,
-            height: size.height * 0.6 + 176,
-            animateTo: true,
-            // 不使用popup时,useSafeArea无效
-            useSafeArea: true,
-          );
-        },
-      ),
-    ],
-  );
-}
- -
- - - - - -
-Widget _buildBlock(BuildContext context) {
-  final size = MediaQuery.of(context).size;
-  final selected = ValueNotifier>(
-    [DateTime.now().millisecondsSinceEpoch + 30 * 24 * 60 * 60 * 1000],
-  );
-  return Column(
-    // spacing: TTheme.of(context).spacer16,
-    crossAxisAlignment: CrossAxisAlignment.start,
-    children: [
-      Row(
-        // spacing: TTheme.of(context).spacer16,
-        mainAxisAlignment: MainAxisAlignment.center,
-        children: [
-          TButton(
-            text: '加一个月',
-            theme: TButtonTheme.primary,
-            onTap: () {
-              selected.value = [selected.value[0] + 30 * 24 * 60 * 60 * 1000];
-            },
-          ),
-          const SizedBox(width: 16),
-          TButton(
-            text: '减一个月',
-            theme: TButtonTheme.primary,
-            onTap: () {
-              selected.value = [selected.value[0] - 30 * 24 * 60 * 60 * 1000];
+                ],
+              );
             },
-          ),
-        ],
-      ),
-      const SizedBox(height: 16),
-      ValueListenableBuilder(
-        valueListenable: selected,
-        builder: (context, value, child) {
-          return TCalendar(
-            title: '请选择日期',
-            value: value,
-            height: size.height * 0.6 + 176,
-            animateTo: true,
-            // 不使用popup时,useSafeArea无效
-            useSafeArea: true,
           );
         },
-      ),
-    ],
+      );
+    },
   );
 }
@@ -826,307 +413,7 @@ Widget _buildBlock(BuildContext context) {
 Widget _buildLunar(BuildContext context) {
-  final size = MediaQuery.of(context).size;
-  final dataSource = LunarDataSourceExample();
-  
-  // 当前月份状态
-  final currentMonth = ValueNotifier(
-    DateTime(DateTime.now().year, DateTime.now().month, 1),
-  );
-  
-  // 农历开关状态
-  final showLunarInfo = ValueNotifier(true);
-  
-  // 选中日期
-  final selectedDate = ValueNotifier>([
-    DateTime.now().millisecondsSinceEpoch,
-  ]);
-
-  return Column(
-    crossAxisAlignment: CrossAxisAlignment.start,
-    children: [
-      // 控制栏
-      Container(
-        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
-        decoration: BoxDecoration(
-          color: Colors.grey.shade50,
-          border: Border(
-            bottom: BorderSide(color: Colors.grey.shade200),
-          ),
-        ),
-        child: ValueListenableBuilder(
-          valueListenable: currentMonth,
-          builder: (context, month, child) {
-            // 获取当前月份的农历信息
-            final lunarInfo = dataSource.getLunarInfo(month);
-            final lunarMonth = lunarInfo != null 
-                ? '${lunarInfo.yearText}年 ${lunarInfo.monthText}' 
-                : '';
-
-            return Column(
-              crossAxisAlignment: CrossAxisAlignment.start,
-              children: [
-                // 农历年月显示
-                if (lunarMonth.isNotEmpty)
-                  Padding(
-                    padding: const EdgeInsets.only(bottom: 8),
-                    child: Text(
-                      lunarMonth,
-                      style: TextStyle(
-                        fontSize: 14,
-                        color: Colors.grey.shade700,
-                        fontWeight: FontWeight.w500,
-                      ),
-                    ),
-                  ),
-                // 按钮行
-                Row(
-                  children: [
-                    // 上一月按钮
-                    TButton(
-                      text: '上一月',
-                      size: TButtonSize.small,
-                      theme: TButtonTheme.primary,
-                      onTap: () {
-                        currentMonth.value = DateTime(
-                          month.year,
-                          month.month - 1,
-                          1,
-                        );
-                        selectedDate.value = [currentMonth.value.millisecondsSinceEpoch];
-                      },
-                    ),
-                    const SizedBox(width: 8),
-                    // 年份选择
-                    Expanded(
-                      child: TButton(
-                        text: '${month.year}年',
-                        size: TButtonSize.small,
-                        theme: TButtonTheme.defaultTheme,
-                        onTap: () async {
-                          final year = await showModalBottomSheet(
-                            context: context,
-                            builder: (context) {
-                              return SizedBox(
-                                height: 300,
-                                child: Column(
-                                  children: [
-                                    Padding(
-                                      padding: const EdgeInsets.all(16),
-                                      child: Text(
-                                        '选择年份',
-                                        style: TextStyle(
-                                          fontSize: 18,
-                                          fontWeight: FontWeight.bold,
-                                        ),
-                                      ),
-                                    ),
-                                    Expanded(
-                                      child: ListView.builder(
-                                        itemCount: 50,
-                                        itemBuilder: (context, index) {
-                                          final year = DateTime.now().year - 10 + index;
-                                          final isSelected = year == month.year;
-                                          return ListTile(
-                                            title: Text(
-                                              '$year年',
-                                              style: TextStyle(
-                                                color: isSelected ? Colors.blue : null,
-                                                fontWeight: isSelected ? FontWeight.bold : null,
-                                              ),
-                                            ),
-                                            onTap: () => Navigator.pop(context, year),
-                                          );
-                                        },
-                                      ),
-                                    ),
-                                  ],
-                                ),
-                              );
-                            },
-                          );
-                          if (year != null) {
-                            currentMonth.value = DateTime(year, month.month, 1);
-                            selectedDate.value = [currentMonth.value.millisecondsSinceEpoch];
-                          }
-                        },
-                      ),
-                    ),
-                    const SizedBox(width: 8),
-                    // 月份选择
-                    Expanded(
-                      child: TButton(
-                        text: '${month.month}月',
-                        size: TButtonSize.small,
-                        theme: TButtonTheme.defaultTheme,
-                        onTap: () async {
-                          final selectedMonth = await showModalBottomSheet(
-                            context: context,
-                            builder: (context) {
-                              return SizedBox(
-                                height: 400,
-                                child: Column(
-                                  children: [
-                                    Padding(
-                                      padding: const EdgeInsets.all(16),
-                                      child: Text(
-                                        '选择月份',
-                                        style: TextStyle(
-                                          fontSize: 18,
-                                          fontWeight: FontWeight.bold,
-                                        ),
-                                      ),
-                                    ),
-                                    Expanded(
-                                      child: GridView.builder(
-                                        padding: const EdgeInsets.all(16),
-                                        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
-                                          crossAxisCount: 3,
-                                          childAspectRatio: 2,
-                                          crossAxisSpacing: 10,
-                                          mainAxisSpacing: 10,
-                                        ),
-                                        itemCount: 12,
-                                        itemBuilder: (context, index) {
-                                          final m = index + 1;
-                                          final isSelected = m == month.month;
-                                          return InkWell(
-                                            onTap: () => Navigator.pop(context, m),
-                                            child: Container(
-                                              alignment: Alignment.center,
-                                              decoration: BoxDecoration(
-                                                color: isSelected ? Colors.blue : Colors.grey.shade200,
-                                                borderRadius: BorderRadius.circular(8),
-                                              ),
-                                              child: Text(
-                                                '$m月',
-                                                style: TextStyle(
-                                                  color: isSelected ? Colors.white : Colors.black,
-                                                  fontWeight: isSelected ? FontWeight.bold : null,
-                                                ),
-                                              ),
-                                            ),
-                                          );
-                                        },
-                                      ),
-                                    ),
-                                  ],
-                                ),
-                              );
-                            },
-                          );
-                          if (selectedMonth != null) {
-                            currentMonth.value = DateTime(month.year, selectedMonth, 1);
-                            selectedDate.value = [currentMonth.value.millisecondsSinceEpoch];
-                          }
-                        },
-                      ),
-                    ),
-                    const SizedBox(width: 8),
-                    // 下一月按钮
-                    TButton(
-                      text: '下一月',
-                      size: TButtonSize.small,
-                      theme: TButtonTheme.primary,
-                      onTap: () {
-                        currentMonth.value = DateTime(
-                          month.year,
-                          month.month + 1,
-                          1,
-                        );
-                        selectedDate.value = [currentMonth.value.millisecondsSinceEpoch];
-                      },
-                    ),
-                    const SizedBox(width: 16),
-                    // 农历开关
-                    ValueListenableBuilder(
-                      valueListenable: showLunarInfo,
-                      builder: (context, show, child) {
-                        return Row(
-                          mainAxisSize: MainAxisSize.min,
-                          children: [
-                            Text(
-                              '农历',
-                              style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
-                            ),
-                            Switch(
-                              value: show,
-                              onChanged: (value) {
-                                showLunarInfo.value = value;
-                              },
-                              materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
-                            ),
-                          ],
-                        );
-                      },
-                    ),
-                  ],
-                ),
-              ],
-            );
-          },
-        ),
-      ),
-      const SizedBox(height: 16),
-      // 日历主体
-      ValueListenableBuilder(
-        valueListenable: showLunarInfo,
-        builder: (context, show, child) {
-          return ValueListenableBuilder(
-            valueListenable: selectedDate,
-            builder: (context, value, child) {
-              return TCalendar(
-                title: '',
-                showLunarInfo: show,
-                dataSource: dataSource,
-                value: value,
-                height: size.height * 0.6,
-                onChange: (newValue) {
-                  selectedDate.value = newValue;
-                  
-                  // 显示完整农历信息
-                  final date = DateTime.fromMillisecondsSinceEpoch(newValue[0]);
-                  final lunarInfo = dataSource.getLunarInfo(date);
-                  final solarTerm = dataSource.getSolarTerm(date);
-                  final festival = dataSource.getFestival(date, lunarInfo);
-                  final holidayInfo = dataSource.getHolidayInfo(date);
-                  
-                  final buffer = StringBuffer();
-                  buffer.write('阳历:${date.year}年${date.month}月${date.day}日');
-                  
-                  if (lunarInfo != null) {
-                    buffer.write('\n农历:${lunarInfo.monthText}${lunarInfo.dayText}');
-                  }
-                  
-                  if (solarTerm != null && solarTerm.isNotEmpty) {
-                    buffer.write('\n节气:$solarTerm');
-                  }
-                  
-                  if (festival != null && festival.isNotEmpty) {
-                    buffer.write('\n节日:$festival');
-                  }
-                  
-                  if (holidayInfo != null) {
-                    final type = holidayInfo['type'] == 'holiday' ? '假期' : '调休';
-                    buffer.write('\n$type:${holidayInfo['name']}');
-                  }
-                  
-                  ScaffoldMessenger.of(context).clearSnackBars();
-                  ScaffoldMessenger.of(context).showSnackBar(
-                    SnackBar(
-                      content: Text(buffer.toString()),
-                      duration: const Duration(seconds: 3),
-                      behavior: SnackBarBehavior.floating,
-                    ),
-                  );
-                },
-              );
-            },
-          );
-        },
-      ),
-    ],
-  );
+  return const _LunarCalendarDemo();
 }
@@ -1137,307 +424,7 @@ Widget _buildLunar(BuildContext context) {
 Widget _buildLunar(BuildContext context) {
-  final size = MediaQuery.of(context).size;
-  final dataSource = LunarDataSourceExample();
-  
-  // 当前月份状态
-  final currentMonth = ValueNotifier(
-    DateTime(DateTime.now().year, DateTime.now().month, 1),
-  );
-  
-  // 农历开关状态
-  final showLunarInfo = ValueNotifier(true);
-  
-  // 选中日期
-  final selectedDate = ValueNotifier>([
-    DateTime.now().millisecondsSinceEpoch,
-  ]);
-
-  return Column(
-    crossAxisAlignment: CrossAxisAlignment.start,
-    children: [
-      // 控制栏
-      Container(
-        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
-        decoration: BoxDecoration(
-          color: Colors.grey.shade50,
-          border: Border(
-            bottom: BorderSide(color: Colors.grey.shade200),
-          ),
-        ),
-        child: ValueListenableBuilder(
-          valueListenable: currentMonth,
-          builder: (context, month, child) {
-            // 获取当前月份的农历信息
-            final lunarInfo = dataSource.getLunarInfo(month);
-            final lunarMonth = lunarInfo != null 
-                ? '${lunarInfo.yearText}年 ${lunarInfo.monthText}' 
-                : '';
-
-            return Column(
-              crossAxisAlignment: CrossAxisAlignment.start,
-              children: [
-                // 农历年月显示
-                if (lunarMonth.isNotEmpty)
-                  Padding(
-                    padding: const EdgeInsets.only(bottom: 8),
-                    child: Text(
-                      lunarMonth,
-                      style: TextStyle(
-                        fontSize: 14,
-                        color: Colors.grey.shade700,
-                        fontWeight: FontWeight.w500,
-                      ),
-                    ),
-                  ),
-                // 按钮行
-                Row(
-                  children: [
-                    // 上一月按钮
-                    TButton(
-                      text: '上一月',
-                      size: TButtonSize.small,
-                      theme: TButtonTheme.primary,
-                      onTap: () {
-                        currentMonth.value = DateTime(
-                          month.year,
-                          month.month - 1,
-                          1,
-                        );
-                        selectedDate.value = [currentMonth.value.millisecondsSinceEpoch];
-                      },
-                    ),
-                    const SizedBox(width: 8),
-                    // 年份选择
-                    Expanded(
-                      child: TButton(
-                        text: '${month.year}年',
-                        size: TButtonSize.small,
-                        theme: TButtonTheme.defaultTheme,
-                        onTap: () async {
-                          final year = await showModalBottomSheet(
-                            context: context,
-                            builder: (context) {
-                              return SizedBox(
-                                height: 300,
-                                child: Column(
-                                  children: [
-                                    Padding(
-                                      padding: const EdgeInsets.all(16),
-                                      child: Text(
-                                        '选择年份',
-                                        style: TextStyle(
-                                          fontSize: 18,
-                                          fontWeight: FontWeight.bold,
-                                        ),
-                                      ),
-                                    ),
-                                    Expanded(
-                                      child: ListView.builder(
-                                        itemCount: 50,
-                                        itemBuilder: (context, index) {
-                                          final year = DateTime.now().year - 10 + index;
-                                          final isSelected = year == month.year;
-                                          return ListTile(
-                                            title: Text(
-                                              '$year年',
-                                              style: TextStyle(
-                                                color: isSelected ? Colors.blue : null,
-                                                fontWeight: isSelected ? FontWeight.bold : null,
-                                              ),
-                                            ),
-                                            onTap: () => Navigator.pop(context, year),
-                                          );
-                                        },
-                                      ),
-                                    ),
-                                  ],
-                                ),
-                              );
-                            },
-                          );
-                          if (year != null) {
-                            currentMonth.value = DateTime(year, month.month, 1);
-                            selectedDate.value = [currentMonth.value.millisecondsSinceEpoch];
-                          }
-                        },
-                      ),
-                    ),
-                    const SizedBox(width: 8),
-                    // 月份选择
-                    Expanded(
-                      child: TButton(
-                        text: '${month.month}月',
-                        size: TButtonSize.small,
-                        theme: TButtonTheme.defaultTheme,
-                        onTap: () async {
-                          final selectedMonth = await showModalBottomSheet(
-                            context: context,
-                            builder: (context) {
-                              return SizedBox(
-                                height: 400,
-                                child: Column(
-                                  children: [
-                                    Padding(
-                                      padding: const EdgeInsets.all(16),
-                                      child: Text(
-                                        '选择月份',
-                                        style: TextStyle(
-                                          fontSize: 18,
-                                          fontWeight: FontWeight.bold,
-                                        ),
-                                      ),
-                                    ),
-                                    Expanded(
-                                      child: GridView.builder(
-                                        padding: const EdgeInsets.all(16),
-                                        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
-                                          crossAxisCount: 3,
-                                          childAspectRatio: 2,
-                                          crossAxisSpacing: 10,
-                                          mainAxisSpacing: 10,
-                                        ),
-                                        itemCount: 12,
-                                        itemBuilder: (context, index) {
-                                          final m = index + 1;
-                                          final isSelected = m == month.month;
-                                          return InkWell(
-                                            onTap: () => Navigator.pop(context, m),
-                                            child: Container(
-                                              alignment: Alignment.center,
-                                              decoration: BoxDecoration(
-                                                color: isSelected ? Colors.blue : Colors.grey.shade200,
-                                                borderRadius: BorderRadius.circular(8),
-                                              ),
-                                              child: Text(
-                                                '$m月',
-                                                style: TextStyle(
-                                                  color: isSelected ? Colors.white : Colors.black,
-                                                  fontWeight: isSelected ? FontWeight.bold : null,
-                                                ),
-                                              ),
-                                            ),
-                                          );
-                                        },
-                                      ),
-                                    ),
-                                  ],
-                                ),
-                              );
-                            },
-                          );
-                          if (selectedMonth != null) {
-                            currentMonth.value = DateTime(month.year, selectedMonth, 1);
-                            selectedDate.value = [currentMonth.value.millisecondsSinceEpoch];
-                          }
-                        },
-                      ),
-                    ),
-                    const SizedBox(width: 8),
-                    // 下一月按钮
-                    TButton(
-                      text: '下一月',
-                      size: TButtonSize.small,
-                      theme: TButtonTheme.primary,
-                      onTap: () {
-                        currentMonth.value = DateTime(
-                          month.year,
-                          month.month + 1,
-                          1,
-                        );
-                        selectedDate.value = [currentMonth.value.millisecondsSinceEpoch];
-                      },
-                    ),
-                    const SizedBox(width: 16),
-                    // 农历开关
-                    ValueListenableBuilder(
-                      valueListenable: showLunarInfo,
-                      builder: (context, show, child) {
-                        return Row(
-                          mainAxisSize: MainAxisSize.min,
-                          children: [
-                            Text(
-                              '农历',
-                              style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
-                            ),
-                            Switch(
-                              value: show,
-                              onChanged: (value) {
-                                showLunarInfo.value = value;
-                              },
-                              materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
-                            ),
-                          ],
-                        );
-                      },
-                    ),
-                  ],
-                ),
-              ],
-            );
-          },
-        ),
-      ),
-      const SizedBox(height: 16),
-      // 日历主体
-      ValueListenableBuilder(
-        valueListenable: showLunarInfo,
-        builder: (context, show, child) {
-          return ValueListenableBuilder(
-            valueListenable: selectedDate,
-            builder: (context, value, child) {
-              return TCalendar(
-                title: '',
-                showLunarInfo: show,
-                dataSource: dataSource,
-                value: value,
-                height: size.height * 0.6,
-                onChange: (newValue) {
-                  selectedDate.value = newValue;
-                  
-                  // 显示完整农历信息
-                  final date = DateTime.fromMillisecondsSinceEpoch(newValue[0]);
-                  final lunarInfo = dataSource.getLunarInfo(date);
-                  final solarTerm = dataSource.getSolarTerm(date);
-                  final festival = dataSource.getFestival(date, lunarInfo);
-                  final holidayInfo = dataSource.getHolidayInfo(date);
-                  
-                  final buffer = StringBuffer();
-                  buffer.write('阳历:${date.year}年${date.month}月${date.day}日');
-                  
-                  if (lunarInfo != null) {
-                    buffer.write('\n农历:${lunarInfo.monthText}${lunarInfo.dayText}');
-                  }
-                  
-                  if (solarTerm != null && solarTerm.isNotEmpty) {
-                    buffer.write('\n节气:$solarTerm');
-                  }
-                  
-                  if (festival != null && festival.isNotEmpty) {
-                    buffer.write('\n节日:$festival');
-                  }
-                  
-                  if (holidayInfo != null) {
-                    final type = holidayInfo['type'] == 'holiday' ? '假期' : '调休';
-                    buffer.write('\n$type:${holidayInfo['name']}');
-                  }
-                  
-                  ScaffoldMessenger.of(context).clearSnackBars();
-                  ScaffoldMessenger.of(context).showSnackBar(
-                    SnackBar(
-                      content: Text(buffer.toString()),
-                      duration: const Duration(seconds: 3),
-                      behavior: SnackBarBehavior.floating,
-                    ),
-                  );
-                },
-              );
-            },
-          );
-        },
-      ),
-    ],
-  );
+  return const _LunarCalendarDemo();
 }
@@ -1448,61 +435,115 @@ Widget _buildLunar(BuildContext context) { ### TCalendar #### 简介 日历组件 + +#### 静态方法 + +##### TCalendar.showPopup + +弹出日历选择器,返回选中的日期列表。 +取消或关闭弹窗时返回 `null`;点击确认时返回选中的 `DateTime` 列表。 +弹窗内点选过程无 `onChange`;实时联动请用 `popupOverlayBuilder` 的 `dates`, +或自行用 `TCalendarInherited` 监听 `TCalendarInherited.selectedListenable`。 +```dart +final result = await TCalendar.showPopup( + context, + titleWidget: Text('请选择日期'), + type: CalendarType.single, +); +if (result != null) { + print('选中了: $result'); +} +``` +若需完全自定义布局,请直接使用 `TCalendar` + `TPopup.show` +/ `TPopupOptions.bottom` 自行组装。 + +返回类型:`Future?>` + +| 参数 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| context | BuildContext | - | - | +| titleWidget | Widget? | - | 标题组件,可传入 Text 或自定义 Widget | +| type | CalendarType | CalendarType.single | 日历的选择模式,决定点击日期后的选中行为: - `CalendarType.single`:单选,点击新日期取消旧选中 - `CalendarType.multiple`:多选,点击切换选中/取消 - `CalendarType.range`:区间选择,依次选起止日期 | +| initialValue | List? | - | 初始选中日期列表,不传则默认今天。 **非受控语义**:仅用于首次挂载;用户点选后以 `onChange` 为准,由调用方自行 `setState` 保存。若父组件在运行期修改本参数,会同步选中态并刷新格子(与 range 行为一致)。 列表长度与 `type` 对应: - `CalendarType.single`:1 个元素(选中日期) - `CalendarType.multiple`:N 个元素(所有选中日期) - `CalendarType.range`:2 个元素(起始、结束日期) | +| minDate | DateTime? | - | 最小可选的日期,不传则默认 1970-01-01 | +| maxDate | DateTime? | - | 最大可选的日期,不传则默认 2100-12-31 | +| anchorDate | DateTime? | - | 锚点日期,打开时滚动到该日期所在月份。 | +| anchorRevision | int | 0 | 锚点滚动触发序号,默认 `0`。 与 `anchorDate` 配合:序号递增可重复滚到同一月份;仅改月份时也可只更新 `anchorDate`。 | +| popupHeight | double? | - | - | +| firstDayOfWeek | int | 0 | 第一天从星期几开始,0 = 周日,1 = 周一,…,6 = 周六。默认 0(周日)。 | +| cellHeight | double? | - | 日期单元格高度,默认 60。如需更大行高可传入自定义值(如 80) | +| style | TCalendarStyle? | - | 自定义样式 | +| popupOverlayBuilder | Widget Function(BuildContext context, List selectedDates)? | - | - | +| popupOverlayExpanded | ValueListenable? | - | - | +| confirmBtnBuilder | Widget Function(VoidCallback onConfirm)? | - | - | +| onConfirm | void Function(List)? | - | - | +| onClose | VoidCallback? | - | - | +| onCellClick | void Function(DateTime value, DateSelectType selectType, TCalendarCellModel cell)? | - | 点击日期时触发 | +| cellBuilder | TCalendarCellBuilder? | - | 整格自定义;设置后不再使用默认主区/副标题布局。 | +| subtitleBuilder | TCalendarSubtitleBuilder? | - | 副标题完全自定义;未设置时可使用 `dataSource.getSubtitle`。 | +| dataSource | TCalendarDataSource? | - | 可选数据源,提供副标题字符串(无 `subtitleBuilder` 时生效)。 | +| onMonthChange | ValueChanged? | - | 月份变化时触发 | +| monthTitleBuilder | Widget Function(BuildContext context, DateTime monthDate)? | - | 月标题构建器 | + #### 默认构造方法 | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| anchorDate | DateTime? | - | 锚点日期 | -| animateTo | bool? | false | 动画滚动到指定位置 | -| cellHeight | double? | 60 | 日期高度 | -| cellWidget | Widget? Function(BuildContext context, TDate tdate, DateSelectType selectType)? | - | 自定义日期单元格组件 | -| dataSource | TCalendarDataSource? | - | 外部数据源,用于提供农历转换等功能 | -| dateType | TCalendarDateType | TCalendarDateType.solar | 日历类型:阳历或农历 | -| displayFormat | String? | 'year month' | 年月显示格式,`year`表示年,`month`表示月,如`year month`表示年在前、月在后、中间隔一个空格 | -| firstDayOfWeek | int? | 0 | 第一天从星期几开始,默认 0 = 周日 | -| format | CalendarFormat? | - | 用于格式化日期的函数,可定义日期前后的显示内容和日期样式 | -| height | double? | - | 高度 | -| isTimeUnit | bool? | true | 是否显示时间单位 | +| anchorDate | DateTime? | - | 锚点日期,打开时滚动到该日期所在月份。 | +| anchorRevision | int | 0 | 锚点滚动触发序号,默认 `0`。 与 `anchorDate` 配合:序号递增可重复滚到同一月份;仅改月份时也可只更新 `anchorDate`。 | +| animateTo | bool | false | 滚动到选中日期/锚点日期所在月份时是否使用动画,默认 false | +| cellBuilder | TCalendarCellBuilder? | - | 整格自定义;设置后不再使用默认主区/副标题布局。 | +| cellHeight | double? | - | 日期单元格高度,默认 60。如需更大行高可传入自定义值(如 80) | +| dataSource | TCalendarDataSource? | - | 可选数据源,提供副标题字符串(无 `subtitleBuilder` 时生效)。 | +| firstDayOfWeek | int | 0 | 第一天从星期几开始,0 = 周日,1 = 周一,…,6 = 周六。默认 0(周日)。 | +| height | double? | - | 高度,不传时内嵌模式自动按 5 行日期计算 | +| initialValue | List? | - | 初始选中日期列表,不传则默认今天。 **非受控语义**:仅用于首次挂载;用户点选后以 `onChange` 为准,由调用方自行 `setState` 保存。若父组件在运行期修改本参数,会同步选中态并刷新格子(与 range 行为一致)。 列表长度与 `type` 对应: - `CalendarType.single`:1 个元素(选中日期) - `CalendarType.multiple`:N 个元素(所有选中日期) - `CalendarType.range`:2 个元素(起始、结束日期) | | key | Key? | - | 组件标识,用于区分或保留组件状态。 | -| maxDate | int? | - | 最大可选的日期(fromMillisecondsSinceEpoch),不传则默认半年后 | -| minDate | int? | - | 最小可选的日期(fromMillisecondsSinceEpoch),不传则默认今天 | +| maxDate | DateTime? | - | 最大可选的日期,不传则默认 2100-12-31 | +| minDate | DateTime? | - | 最小可选的日期,不传则默认 1970-01-01 | | monthTitleBuilder | Widget Function(BuildContext context, DateTime monthDate)? | - | 月标题构建器 | -| monthTitleHeight | double? | 22 | 月标题高度 | -| onCellClick | void Function(int value, DateSelectType type, TDate tdate)? | - | 点击日期时触发 | -| onCellLongPress | void Function(int value, DateSelectType type, TDate tdate)? | - | 长安日期时触发 | -| onChange | void Function(List value)? | - | 选中值变化时触发 | -| onHeaderClick | void Function(int index, String week)? | - | 点击周时触发 | +| monthTitleHeight | double | 22 | 每月标题行高度(如 '2025年6月' 所在行),默认 22 | +| onCellClick | void Function(DateTime value, DateSelectType selectType, TCalendarCellModel cell)? | - | 点击日期时触发 | +| onChange | void Function(List value)? | - | 选中值变化时触发 | | onMonthChange | ValueChanged? | - | 月份变化时触发 | -| pickerHeight | double? | 178 | 时间选择器List的视窗高度 | -| pickerItemCount | int? | 3 | 选择器List视窗中item个数,pickerHeight / pickerItemCount即item高度 | -| showLunarInfo | bool | false | 阳历模式下是否显示农历信息作为副标题 | +| safeAreaInset | bool | true | 是否适配底部安全区域(如 iPhone Home Indicator),默认 true | | style | TCalendarStyle? | - | 自定义样式 | -| timePickerModel | List? | - | 自定义时间选择器 | -| title | String? | - | 标题 | -| titleWidget | Widget? | - | 标题组件 | -| type | CalendarType? | CalendarType.single | 日历的选择类型,single = 单选;multiple = 多选;range = 区间选择 | -| useSafeArea | bool? | true | 是否使用安全区域,默认true | -| useTimePicker | bool? | false | 是否显示时间选择器 | -| value | List? | - | 当前选择的日期(fromMillisecondsSinceEpoch),不传则默认今天,当 type = single 时数组长度为1 | -| width | double? | - | 宽度 | +| subtitleBuilder | TCalendarSubtitleBuilder? | - | 副标题完全自定义;未设置时可使用 `dataSource.getSubtitle`。 | +| titleWidget | Widget? | - | 标题组件,可传入 Text 或自定义 Widget | +| type | CalendarType | CalendarType.single | 日历的选择模式,决定点击日期后的选中行为: - `CalendarType.single`:单选,点击新日期取消旧选中 - `CalendarType.multiple`:多选,点击切换选中/取消 - `CalendarType.range`:区间选择,依次选起止日期 | -### TCalendarPopup +### TCalendarInherited #### 简介 -单元格组件popup模式 +日历弹窗状态的 InheritedWidget 容器。 +由上层(如 `TSlidePopupRoute` 的 builder)包裹在 `TCalendar` 外侧, +将选中态、确认/关闭回调等注入子树。 + +#### 静态方法 + +##### TCalendarInherited.of + +返回类型:`TCalendarInherited?` + +| 参数 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| context | BuildContext | - | - | + #### 默认构造方法 | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| context | BuildContext | - | 上下文 | -| autoClose | bool? | true | 自动关闭;在点击关闭按钮、确认按钮、遮罩层时自动关闭 | -| builder | CalendarBuilder? | - | 控件构建器,优先级高于`child` | -| child | TCalendar? | - | 日历控件 | -| confirmBtn | Widget? | - | 自定义确认按钮 | -| onClose | VoidCallback? | - | 关闭时触发 | -| onConfirm | void Function(List value)? | - | 点击确认按钮时触发 | -| top | double? | - | 距离顶部的距离 | -| visible | bool? | - | 默认是否显示日历 | +| child | Widget | - | - | +| confirmBtnBuilder | Widget Function(VoidCallback onConfirm)? | - | 自定义确认按钮;`onConfirm` 与默认确认按钮一致(回传选中值并关闭弹窗)。 | +| key | Key? | - | 组件标识,用于区分或保留组件状态。 | +| onClose | VoidCallback? | - | - | +| onConfirm | VoidCallback? | - | - | +| popupConfirmBtn | bool? | - | 是否由 `TCalendar` 渲染底部确认按钮。 为 `null`(默认)时跟随 `popupControls`;显式设置时覆盖。 | +| popupControls | bool | true | 是否由 `TCalendar` 自行渲染关闭按钮和标题行。 为 `true`(默认)时 `TCalendar` 渲染关闭按钮与标题行; 为 `false` 时由外层弹窗容器承载。 | +| popupOverlayBuilder | Widget Function(BuildContext context, List selectedDates)? | - | 弹窗模式下日历内容区底部浮层构建器(非 `TPopup` 面板底部)。 由 `TCalendar.showPopup` 或手动 `TCalendarInherited` 注入; `selectedDates` 随点选实时更新。 | +| popupOverlayExpanded | ValueListenable? | - | 浮层是否展开(响应式),需配合 `popupOverlayBuilder`。 | +| selected | ValueNotifier> | - | 选中态的可写引用(仅供 `TCalendar` 内部更新使用)。 对外消费方请使用 `selectedListenable` 这一只读视图。 | +| usePopup | bool? | true | - | ### TCalendarStyle @@ -1511,9 +552,9 @@ Widget _buildLunar(BuildContext context) { #### 工厂构造方法 -##### TCalendarStyle.cellStyle +##### TCalendarStyle.forSelectType -日期样式 +按选中态生成单元格样式 | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | @@ -1534,15 +575,15 @@ Widget _buildLunar(BuildContext context) { | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | cellDecoration | BoxDecoration? | - | 日期decoration | -| cellPrefixStyle | TextStyle? | - | 日期前面的字符串的样式 | -| cellStyle | TextStyle? | - | 日期样式 | -| cellSuffixStyle | TextStyle? | - | 日期后面的字符串的样式 | | centreColor | Color? | - | 日期范围内背景样式 | +| dayStyle | TextStyle? | - | 日期主区(默认阳历日数字)样式 | | decoration | BoxDecoration? | - | - | | monthTitleStyle | TextStyle? | - | body区域 年月文字样式 | +| subtitleStyle | TextStyle? | - | 副标题样式(仅 `TCalendarDataSource.getSubtitle` 字符串路径使用) | | titleCloseColor | Color? | - | header区域 关闭图标的颜色 | -| titleMaxLine | int? | - | header区域 `TCalendar.title`的行数 | -| titleStyle | TextStyle? | - | header区域 `TCalendar.title`的样式 | +| titleMaxLine | int? | - | header区域 `TCalendar.titleWidget`的行数 | +| titleStyle | TextStyle? | - | header区域 `TCalendar.titleWidget`的样式 | +| todayDayStyle | TextStyle? | - | 今天日期主区样式 | | weekdayStyle | TextStyle? | - | header区域 周 文字样式 | #### 公开属性 @@ -1550,82 +591,50 @@ Widget _buildLunar(BuildContext context) { | 属性 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | bodyPadding | double? | - | 月与月之间的垂直间距 | -| todayStyle | TextStyle? | - | 当天日期样式 | | verticalGap | double? | - | 日期垂直间距,水平间距为`verticalGap` / 2 | ### TCalendarDataSource #### 简介 -日历数据源接口 - -开发者需要实现此接口来提供农历转换能力。 -组件内部不包含农历算法和数据,完全依赖外部实现。 +日历可选数据源:仅提供副标题文案(无 `subtitleBuilder` 时使用)。 +农历、节气、节日等均由接入方在 `TCalendar.subtitleBuilder` 或 +`getSubtitle` 中自行处理;组件主区默认只渲染阳历日数字。 #### 方法 | 名称 | 返回类型 | 参数 | 说明 | | --- | --- | --- | --- | -| getLunarInfo | TLunarInfo? | required DateTime solarDate | 获取指定阳历日期的农历信息 返回 null 表示不显示农历信息 | -| formatDate | String | required DateTime date, required TCalendarDateType type, TLunarInfo? lunarInfo | 格式化日期文本 返回格式化后的日期字符串 | -| getSolarTerm | String? | required DateTime date | 获取节气信息(可选实现) 返回节气名称,如"春分"、"秋分"等,无节气则返回 null | -| getFestival | String? | required DateTime date, TLunarInfo? lunarInfo | 获取节日信息(可选实现) 返回节日名称,如"春节"、"中秋节"等,无节日则返回 null | -| getHolidayInfo | Map? | required DateTime date | 获取假期信息(可选实现) 返回假期类型和名称: - 'holiday': 法定节假日/公共假期(如"国庆节") - 'workday': 调休工作日(如"补班") - null: 正常日期 示例返回值: - {'type': 'holiday', 'name': '国庆节'} - {'type': 'workday', 'name': '补班'} - null | -| formatYear | String | required int year, required TCalendarDateType type | 格式化年份文本 返回格式化后的年份字符串 阳历示例:2025 -> "2025年" 阴历示例:2025 -> "二〇二五年" | -| formatMonth | String | required int month, required TCalendarDateType type, bool isLeapMonth | 格式化月份文本 返回格式化后的月份字符串 阳历示例:3 -> "3月" 阴历示例:3 -> "三月",闰3月 -> "闰三月" | -| formatDay | String | required int day, required TCalendarDateType type | 格式化日期文本 返回格式化后的日期字符串 阳历示例:7 -> "7日" 阴历示例:7 -> "初七" | +| getSubtitle | String? | required DateTime date | 副标题文案;返回 null 或空字符串时不显示副标题行。 | -### TLunarInfo +### TCalendarCellModel #### 简介 -农历日期信息模型 +单个日期格数据(只读,选中态通过 `typeNotifier` 更新) #### 默认构造方法 | 参数 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| day | int | - | 农历日期(数字,1-30) | -| dayText | String | - | 日期文本(如:初七) | -| isLeapMonth | bool | false | 是否是闰月 | -| month | int | - | 农历月份(数字,1-12) | -| monthText | String | - | 月份文本(如:三月、闰三月) | -| year | int | - | 农历年份(数字) | -| yearText | String | - | 年份文本(如:二〇二五) | - - -### TCalendarDateType -#### 简介 -日历类型枚举 -#### 枚举值 - - -| 名称 | 说明 | -| --- | --- | -| solar | 阳历(公历) | -| lunar | 阴历(农历) | +| date | DateTime | - | - | +| isLastDayOfMonth | bool | - | - | +| typeNotifier | DateSelectTypeNotifier | - | - | ### CalendarType +#### 简介 +日历选择模式 #### 枚举值 | 名称 | 说明 | | --- | --- | -| single | - | -| multiple | - | -| range | - | - - -### CalendarTrigger -#### 枚举值 - - -| 名称 | 说明 | -| --- | --- | -| closeBtn | - | -| confirmBtn | - | -| overlay | - | +| single | 单选:点击新日期时自动取消旧日期的选中状态 | +| multiple | 多选:点击日期切换选中/取消,可同时选中多个日期 | +| range | 区间选择:第一次点击选起点,第二次点击选终点,中间自动填充 | ### DateSelectType +#### 简介 +日期在日历格中的选中/展示状态 #### 枚举值 @@ -1639,19 +648,23 @@ Widget _buildLunar(BuildContext context) { | empty | - | -### CalendarFormat +### TCalendarSubtitleBuilder +#### 简介 +副标题完全自定义 #### 类型定义 ```dart -typedef CalendarFormat = TDate? Function(TDate? day); +typedef TCalendarSubtitleBuilder = Widget? Function(BuildContext context, TCalendarSubtitleContext subtitleContext); ``` -### CalendarBuilder +### TCalendarCellBuilder +#### 简介 +整格自定义(主区 + 副标题均由接入方绘制) #### 类型定义 ```dart -typedef CalendarBuilder = Widget Function(BuildContext context); +typedef TCalendarCellBuilder = Widget? Function(BuildContext context, TCalendarCellModel cell); ```