import 'package:flutter/material.dart'; import '../core/theme/context_tokens.dart'; import '../core/theme/app_tokens.dart'; import '../core/theme/app_dims.g.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; const WheelDatePanel({ super.key, required this.initial, required this.onCommit, this.title = '', }); @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(() {}); } 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)), ], ), ], ), ), // 底栏:今天(左) / 确定(右,无取消,点外部取消) 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); }