import 'dart:math' as math; import 'package:flutter/material.dart'; import '../core/responsive/responsive.dart'; import '../core/theme/context_tokens.dart'; import '../core/theme/app_tokens.dart'; import '../core/theme/app_dims.g.dart'; import '../core/utils/date_util.dart'; import '../core/utils/dialog_util.dart'; import '../core/theme/app_fonts.dart'; /// 年/月/日 滚轮日期选择面板(对齐原型单一真源 .wheel-pop / datewheel.js)。 /// 三列纯数字(年 4 位、月/日补零两位,等宽字体),中间高亮带 = .wheel-center, /// 居中项为 primary 加粗 16px;底部「今天」+「确定」,无取消(点外部取消)。 /// /// 本体是自包含的 248px 卡片(Material 描边+阴影),既可放进锚定浮层(单据/生产日期, /// 挂在字段下方,对齐原型 datewheel.js 的 pop 定位),也可放进居中弹窗(范围选择)。 /// 「确定」时回调 [onCommit];「今天」仅在面板内滚动到今天,不提交。 class WheelDatePanel extends StatefulWidget { final DateTime initial; final ValueChanged onCommit; /// 可选标题(范围选择用「起始/结束日期」区分);空则不渲染(对齐原型无标题)。 final String title; /// 滚动即回调当前值(范围下拉的联动输入框用);null 不回调。 final ValueChanged? onChanged; /// false 隐藏「今天/确定」底栏(范围下拉共用一个底栏时用),onCommit 不再触发。 final bool showFooter; const WheelDatePanel({ super.key, required this.initial, required this.onCommit, this.title = '', this.onChanged, this.showFooter = true, }); @override State createState() => _WheelDatePanelState(); } const double _kItem = 36; // .wheel-item 行高 const double _kColsH = 180; // .wheel-cols 高度(5 行) const double _kWidth = 248; // .wheel-pop 宽度 String _pad2(int n) => n.toString().padLeft(2, '0'); int _daysIn(int y, int m) => DateTime(y, m + 1, 0).day; // m: 1-12 class _WheelDatePanelState extends State { late final List _years; // now-15 .. now+2(对齐 datewheel.js) late int _yIdx, _mIdx, _dIdx; late final FixedExtentScrollController _yCtrl, _mCtrl, _dCtrl; int get _year => _years[_yIdx]; int get _month => _mIdx + 1; @override void initState() { super.initState(); final now = DateTime.now(); _years = [for (var y = now.year - 15; y <= now.year + 2; y++) y]; final v = widget.initial; _yIdx = _years.indexOf(v.year); if (_yIdx < 0) _yIdx = _years.indexOf(now.year); if (_yIdx < 0) _yIdx = 0; _mIdx = (v.month - 1).clamp(0, 11); _dIdx = (v.day - 1).clamp(0, _daysIn(v.year, v.month) - 1); _yCtrl = FixedExtentScrollController(initialItem: _yIdx); _mCtrl = FixedExtentScrollController(initialItem: _mIdx); _dCtrl = FixedExtentScrollController(initialItem: _dIdx); } @override void dispose() { _yCtrl.dispose(); _mCtrl.dispose(); _dCtrl.dispose(); super.dispose(); } // 年/月变化后重算当月天数,超界则回夹并吸附。 void _onYearOrMonth() { final n = _daysIn(_year, _month); if (_dIdx > n - 1) { _dIdx = n - 1; _dCtrl.jumpToItem(_dIdx); } setState(() {}); widget.onChanged?.call(DateTime(_year, _month, _dIdx + 1)); } void _goToday() { final now = DateTime.now(); final yi = _years.indexOf(now.year); if (yi >= 0) _yIdx = yi; _mIdx = now.month - 1; _dIdx = now.day - 1; const d = Duration(milliseconds: 250); _yCtrl.animateToItem(_yIdx, duration: d, curve: Curves.easeOut); _mCtrl.animateToItem(_mIdx, duration: d, curve: Curves.easeOut); _dCtrl.animateToItem(_dIdx, duration: d, curve: Curves.easeOut); setState(() {}); } @override Widget build(BuildContext context) { final t = context.tokens; final days = _daysIn(_year, _month); return Material( color: t.surface, elevation: 6, borderRadius: BorderRadius.circular(AppDims.rMd), child: Container( width: _kWidth, padding: const EdgeInsets.all(8), decoration: BoxDecoration( border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rMd), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ if (widget.title.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(4, 2, 4, 8), child: Align( alignment: Alignment.centerLeft, child: Text(widget.title, style: TextStyle( fontSize: AppDims.fsTitle, fontWeight: FontWeight.w600, color: t.heading)), ), ), // 三列滚轮 + 中间高亮带 SizedBox( height: _kColsH, child: Stack( children: [ Positioned( left: 4, right: 4, top: (_kColsH - _kItem) / 2, height: _kItem, child: IgnorePointer( child: Container( decoration: BoxDecoration( color: t.bg, borderRadius: BorderRadius.circular(AppDims.rMd), border: Border( top: BorderSide(color: t.border), bottom: BorderSide(color: t.border), ), ), ), ), ), Row( children: [ _col(t, _yCtrl, _years.length, (i) => _years[i].toString(), _yIdx, (i) { _yIdx = i; _onYearOrMonth(); }), _col(t, _mCtrl, 12, (i) => _pad2(i + 1), _mIdx, (i) { _mIdx = i; _onYearOrMonth(); }), _col(t, _dCtrl, days, (i) => _pad2(i + 1), _dIdx, (i) { setState(() => _dIdx = i); widget.onChanged ?.call(DateTime(_year, _month, _dIdx + 1)); }), ], ), ], ), ), // 底栏:今天(左) / 确定(右,无取消,点外部取消) if (widget.showFooter) Container( margin: const EdgeInsets.only(top: 6), padding: const EdgeInsets.fromLTRB(4, 8, 4, 2), decoration: BoxDecoration( border: Border(top: BorderSide(color: t.borderSubtle)), ), child: Row( children: [ InkWell( onTap: _goToday, borderRadius: BorderRadius.circular(AppDims.rSm), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4), child: Text('今天', style: TextStyle( fontSize: AppDims.fsSm, color: t.muted)), ), ), const Spacer(), InkWell( onTap: () => widget.onCommit(DateTime(_year, _month, _dIdx + 1)), borderRadius: BorderRadius.circular(AppDims.rSm), child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 5), decoration: BoxDecoration( color: t.primary, borderRadius: BorderRadius.circular(AppDims.rSm), ), child: Text('确定', style: TextStyle( fontSize: AppDims.fsSm, color: t.onPrimary, fontWeight: FontWeight.w600)), ), ), ], ), ), ], ), ), ); } Widget _col( AppTokens t, FixedExtentScrollController ctrl, int count, String Function(int) label, int selIdx, ValueChanged onSel, ) { return Expanded( child: ListWheelScrollView.useDelegate( controller: ctrl, itemExtent: _kItem, physics: const FixedExtentScrollPhysics(), diameterRatio: 100, // 近似平面,去掉滚轮弧度 perspective: 0.0001, onSelectedItemChanged: onSel, childDelegate: ListWheelChildBuilderDelegate( childCount: count, builder: (c, i) { final on = i == selIdx; return Center( child: Text( label(i), style: TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontSize: on ? 16 : AppDims.fsBody, fontWeight: on ? FontWeight.w700 : FontWeight.w400, color: on ? t.primary : t.muted, ), ), ); }, ), ), ); } } /// 居中弹窗形式(范围选择用)。锚定字段的用法见 DsDateCell(直接嵌 WheelDatePanel 到浮层)。 Future showWheelDatePicker( BuildContext context, { DateTime? initial, String title = '', }) { return showAppDialog( context: context, builder: (dctx) => Align( alignment: Alignment.center, child: WheelDatePanel( initial: initial ?? DateTime.now(), title: title, onCommit: (d) => Navigator.of(dctx).pop(d), ), ), ); } /// 范围选择:先选起始日、再选结束日;任一取消则返回 null。 Future showWheelDateRange( BuildContext context, { DateTimeRange? initial, }) async { final start = await showWheelDatePicker(context, initial: initial?.start, title: '起始日期'); if (start == null || !context.mounted) return null; final end = await showWheelDatePicker(context, initial: initial?.end ?? start, title: '结束日期'); if (end == null) return null; return end.isBefore(start) ? DateTimeRange(start: end, end: start) : DateTimeRange(start: start, end: end); } // ═══════════════ 日期范围下拉(桌面)═══════════════ // 锚定触发控件下方的双列面板:起始 | 结束 并排各一组「可输入框 + 滚轮」, // 透明遮罩点外关闭(不冻结窗口,形态同 DsMenu 下拉);共用一个「确定」底栏。 // 窄屏(无悬停、面板超宽)回落到居中滚轮弹窗(showWheelDateRange)。 /// 打开日期范围下拉,锚定在 [anchorContext](触发控件自身的 context)下方。 /// 确定返回 DateTimeRange(起止倒置自动交换);点外部关闭返回 null(不变更)。 Future showDateRangeDropdown( BuildContext anchorContext, { DateTimeRange? initial, }) { if (anchorContext.isMobile) { // 窄屏:双列 530px 放不下,保持既有两步滚轮弹窗 return showWheelDateRange(anchorContext, initial: initial); } final overlay = Navigator.of(anchorContext) .overlay! .context .findRenderObject() as RenderBox; final box = anchorContext.findRenderObject() as RenderBox?; final rect = box == null ? Rect.zero : box.localToGlobal(Offset.zero, ancestor: overlay) & box.size; return Navigator.of(anchorContext).push( _RangeDropdownRoute(anchorRect: rect, initial: initial), ); } class _RangeDropdownRoute extends PopupRoute { final Rect anchorRect; final DateTimeRange? initial; _RangeDropdownRoute({required this.anchorRect, this.initial}); @override Color? get barrierColor => Colors.transparent; @override bool get barrierDismissible => true; @override String? get barrierLabel => 'date-range'; @override Duration get transitionDuration => Duration.zero; @override Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { return CustomSingleChildLayout( delegate: _RangeDropdownLayout(anchorRect), child: _DateRangePanel( initial: initial, onCommit: (r) => Navigator.of(context).pop(r), ), ); } } class _RangeDropdownLayout extends SingleChildLayoutDelegate { final Rect anchor; _RangeDropdownLayout(this.anchor); @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) => BoxConstraints( maxWidth: math.max(0, constraints.maxWidth - 16), maxHeight: math.max(0, constraints.maxHeight - 16), ); @override Offset getPositionForChild(Size size, Size childSize) { final left = anchor.left .clamp(8.0, math.max(8.0, size.width - childSize.width - 8)) .toDouble(); final below = anchor.bottom + 6; final above = anchor.top - childSize.height - 6; final top = (below + childSize.height <= size.height - 8 || above < 8) ? below : above; return Offset(left, top.clamp(8.0, math.max(8.0, size.height - 8))); } @override bool shouldRelayout(_RangeDropdownLayout old) => old.anchor != anchor; } class _DateRangePanel extends StatefulWidget { final DateTimeRange? initial; final ValueChanged onCommit; const _DateRangePanel({this.initial, required this.onCommit}); @override State<_DateRangePanel> createState() => _DateRangePanelState(); } class _DateRangePanelState extends State<_DateRangePanel> { late DateTime _start = widget.initial?.start ?? DateTime.now(); late DateTime _end = widget.initial?.end ?? DateTime.now(); void _commit() { final r = _end.isBefore(_start) ? DateTimeRange(start: _end, end: _start) : DateTimeRange(start: _start, end: _end); widget.onCommit(r); } @override Widget build(BuildContext context) { final t = context.tokens; return Material( color: t.surface, elevation: 10, borderRadius: BorderRadius.circular(AppDims.rLg), child: Container( width: 552, padding: const EdgeInsets.all(12), decoration: BoxDecoration( border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _RangeSide( title: '起始日期', value: _start, onChanged: (d) => setState(() => _start = d), ), ), const SizedBox(width: 12), Expanded( child: _RangeSide( title: '结束日期', value: _end, onChanged: (d) => setState(() => _end = d), ), ), ], ), // 底栏(共用):范围文案(左)/ 确定(右);点外部即取消 Container( margin: const EdgeInsets.only(top: 10), padding: const EdgeInsets.only(top: 10), decoration: BoxDecoration( border: Border(top: BorderSide(color: t.borderSubtle)), ), child: Row( children: [ Expanded( child: Text( '${formatYmd(_start)} ~ ${formatYmd(_end)}', style: TextStyle( fontSize: AppDims.fsSm, color: t.muted, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback), ), ), InkWell( onTap: _commit, borderRadius: BorderRadius.circular(AppDims.rSm), child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 5), decoration: BoxDecoration( color: t.primary, borderRadius: BorderRadius.circular(AppDims.rSm), ), child: Text('确定', style: TextStyle( fontSize: AppDims.fsSm, color: t.onPrimary, fontWeight: FontWeight.w600)), ), ), ], ), ), ], ), ), ); } } /// 范围面板单侧:标题 + 可输入框(键入即联动滚轮)+ 滚轮(滚动即联动输入框)。 class _RangeSide extends StatefulWidget { final String title; final DateTime value; final ValueChanged onChanged; const _RangeSide( {required this.title, required this.value, required this.onChanged}); @override State<_RangeSide> createState() => _RangeSideState(); } class _RangeSideState extends State<_RangeSide> { late final TextEditingController _ctrl = TextEditingController(text: formatYmd(widget.value)); final FocusNode _focus = FocusNode(); // 键入归一后的滚轮锚定值:仅键入产生完整有效日期时更新(驱动滚轮重建吸附) late DateTime _wheelAnchor = widget.value; bool _syncing = false; // 滚轮回写输入框时跳过 onChanged 循环 @override void initState() { super.initState(); _focus.addListener(() { if (!_focus.hasFocus) { // 失焦归一显示(2024 → 2024-01-01)并吸附滚轮 final n = normalizeYmd(_ctrl.text); if (n != null) { _syncing = true; _ctrl.text = n; _syncing = false; _applyTyped(n); } else { _syncing = true; _ctrl.text = formatYmd(widget.value); _syncing = false; } } }); } @override void dispose() { _ctrl.dispose(); _focus.dispose(); super.dispose(); } void _applyTyped(String ymd) { final d = parseYmd(ymd); if (d == null) return; widget.onChanged(d); if (d != _wheelAnchor) setState(() => _wheelAnchor = d); } @override Widget build(BuildContext context) { final t = context.tokens; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 2, bottom: 6), child: Text(widget.title, style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w600, color: t.muted)), ), Container( height: 32, padding: const EdgeInsets.symmetric(horizontal: 10), margin: const EdgeInsets.only(bottom: 6), decoration: BoxDecoration( color: t.surface, border: Border.all(color: _focus.hasFocus ? t.primary : t.border), borderRadius: BorderRadius.circular(AppDims.rMd), ), alignment: Alignment.centerLeft, child: TextField( controller: _ctrl, focusNode: _focus, keyboardType: TextInputType.datetime, style: TextStyle( fontSize: AppDims.fsBody, color: t.text, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback), decoration: InputDecoration( isCollapsed: true, border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, filled: false, hintText: '年-月-日', hintStyle: TextStyle(fontSize: AppDims.fsBody, color: t.faint), ), onChanged: (v) { if (_syncing) return; // 键入完整日期(含 8 位纯数字)即联动滚轮;不完整先不动 final n = normalizeYmd(v); if (n != null && v.trim().length >= 8) _applyTyped(n); }, onSubmitted: (v) { final n = normalizeYmd(v); if (n != null) { _syncing = true; _ctrl.text = n; _syncing = false; _applyTyped(n); } }, ), ), WheelDatePanel( key: ValueKey('${widget.title}-${formatYmd(_wheelAnchor)}'), initial: _wheelAnchor, showFooter: false, onCommit: (_) {}, onChanged: (d) { widget.onChanged(d); _syncing = true; _ctrl.text = formatYmd(d); _syncing = false; }, ), ], ); } }