// widgets/wheel_date_picker.dart — 统一日期选择组件(扁平风格,2026-07-04 拍板)。 // // 形态(桌面=锚定下拉、窄屏=底部 sheet,内容同一套窗格): // - 单日期 showDsDateDropdown:面板 = 可输入框 + 三列滚轮 + 底栏(今天/确定) // - 范围 showDateRangeDropdown:起始|结束 两窗格并排(窄屏纵排)+ 共用底栏(预览/确定) // 窗格(_DatePane):输入框与滚轮同宽(248)、文字水平垂直居中、mono;键入/回车/失焦 // 归一 yyyy-MM-dd 并联动滚轮,滚轮滚动实时回写输入框。全程无阴影/立体效果。 // // 所有日期选择统一走本组件:录单日期格 DsDateCell、登记收支 DatePickerField、 // 列表时间范围(工具栏/详搜)。滚轮本体对齐原型 datewheel.js 三列结构。 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/theme/app_fonts.dart'; import 'ds/m_sheet.dart'; const double _kItem = 36; // .wheel-item 行高 const double _kColsH = 180; // .wheel-cols 高度(5 行) const double _kWidth = 248; // 窗格宽度(输入框与滚轮同宽) String _pad2(int n) => n.toString().padLeft(2, '0'); int _daysIn(int y, int m) => DateTime(y, m + 1, 0).day; // m: 1-12 // ═══════════════ 对外入口 ═══════════════ /// 单日期选择:桌面锚定 [anchorContext] 下方的下拉(透明遮罩点外关闭,不冻结窗口); /// 窄屏底部 sheet。确定返回日期,点外部/取消返回 null。 Future showDsDateDropdown( BuildContext anchorContext, { DateTime? initial, String title = '选择日期', }) { if (anchorContext.isMobile) { return showMSheet( anchorContext, title: title, builder: (ctx) => Center( child: _SingleDateBody( initial: initial ?? DateTime.now(), onCommit: (d) => Navigator.of(ctx).pop(d), ), ), ); } return Navigator.of(anchorContext).push(_DropdownRoute( anchorRect: _anchorRect(anchorContext), builder: (ctx) => _DropCard( child: _SingleDateBody( initial: initial ?? DateTime.now(), onCommit: (d) => Navigator.of(ctx).pop(d), ), ), )); } /// 日期范围选择:桌面锚定下拉(起始|结束并排);窄屏底部 sheet(纵排)。 /// 确定返回 DateTimeRange(起止倒置自动交换),点外部/取消返回 null。 Future showDateRangeDropdown( BuildContext anchorContext, { DateTimeRange? initial, }) { if (anchorContext.isMobile) { return showMSheet( anchorContext, title: '日期区间', builder: (ctx) => Center( child: _RangeBody( initial: initial, vertical: true, onCommit: (r) => Navigator.of(ctx).pop(r), ), ), ); } return Navigator.of(anchorContext) .push(_DropdownRoute( anchorRect: _anchorRect(anchorContext), builder: (ctx) => _DropCard( child: _RangeBody( initial: initial, vertical: false, onCommit: (r) => Navigator.of(ctx).pop(r), ), ), )); } Rect _anchorRect(BuildContext anchorContext) { final overlay = Navigator.of(anchorContext) .overlay! .context .findRenderObject() as RenderBox; final box = anchorContext.findRenderObject() as RenderBox?; return box == null ? Rect.zero : box.localToGlobal(Offset.zero, ancestor: overlay) & box.size; } // ═══════════════ 下拉路由(锚定,透明遮罩)═══════════════ class _DropdownRoute extends PopupRoute { final Rect anchorRect; final WidgetBuilder builder; _DropdownRoute({required this.anchorRect, required this.builder}); @override Color? get barrierColor => Colors.transparent; @override bool get barrierDismissible => true; @override String? get barrierLabel => 'date-dropdown'; @override Duration get transitionDuration => Duration.zero; @override Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { return CustomSingleChildLayout( delegate: _DropdownLayout(anchorRect), child: builder(context), ); } } class _DropdownLayout extends SingleChildLayoutDelegate { final Rect anchor; _DropdownLayout(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(_DropdownLayout old) => old.anchor != anchor; } /// 下拉卡壳(扁平):surface + 1px 边框 + r-lg,无阴影。 class _DropCard extends StatelessWidget { final Widget child; const _DropCard({required this.child}); @override Widget build(BuildContext context) { final t = context.tokens; return Material( color: t.surface, shape: RoundedRectangleBorder( side: BorderSide(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), child: Padding(padding: const EdgeInsets.all(12), child: child), ); } } // ═══════════════ 单日期面板体 ═══════════════ class _SingleDateBody extends StatefulWidget { final DateTime initial; final ValueChanged onCommit; const _SingleDateBody({required this.initial, required this.onCommit}); @override State<_SingleDateBody> createState() => _SingleDateBodyState(); } class _SingleDateBodyState extends State<_SingleDateBody> { late DateTime _value = widget.initial; int _paneEpoch = 0; // 「今天」时驱动窗格重建吸附 @override Widget build(BuildContext context) { final t = context.tokens; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ _DatePane( key: ValueKey('single-$_paneEpoch'), value: _value, onChanged: (d) => _value = d, ), // 底栏(扁平):今天(左)/ 确定(右) Container( width: _kWidth, margin: const EdgeInsets.only(top: 10), padding: const EdgeInsets.only(top: 10), decoration: BoxDecoration( border: Border(top: BorderSide(color: t.borderSubtle)), ), child: Row( children: [ InkWell( onTap: () => setState(() { _value = DateTime.now(); _paneEpoch++; }), 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(), _CommitButton(onTap: () => widget.onCommit(_value)), ], ), ), ], ); } } // ═══════════════ 范围面板体 ═══════════════ class _RangeBody extends StatefulWidget { final DateTimeRange? initial; final bool vertical; // 窄屏纵排 final ValueChanged onCommit; const _RangeBody( {this.initial, required this.vertical, required this.onCommit}); @override State<_RangeBody> createState() => _RangeBodyState(); } class _RangeBodyState extends State<_RangeBody> { late DateTime _start = widget.initial?.start ?? DateTime.now(); late DateTime _end = widget.initial?.end ?? DateTime.now(); void _commit() { widget.onCommit(_end.isBefore(_start) ? DateTimeRange(start: _end, end: _start) : DateTimeRange(start: _start, end: _end)); } @override Widget build(BuildContext context) { final t = context.tokens; final startPane = _DatePane( title: '起始日期', value: _start, onChanged: (d) => setState(() => _start = d), ); final endPane = _DatePane( title: '结束日期', value: _end, onChanged: (d) => setState(() => _end = d), ); final footerWidth = widget.vertical ? _kWidth : _kWidth * 2 + 12; // 与窗格区同宽 return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ if (widget.vertical) ...[ startPane, const SizedBox(height: 14), endPane, ] else Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [startPane, const SizedBox(width: 12), endPane], ), // 共用底栏(扁平):范围预览(左)/ 确定(右) Container( width: footerWidth, 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), ), ), _CommitButton(onTap: _commit), ], ), ), ], ); } } class _CommitButton extends StatelessWidget { final VoidCallback onTap; const _CommitButton({required this.onTap}); @override Widget build(BuildContext context) { final t = context.tokens; return InkWell( onTap: onTap, 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)), ), ); } } // ═══════════════ 窗格:可输入框 + 三列滚轮(同宽 248,扁平)═══════════════ class _DatePane extends StatefulWidget { final String? title; final DateTime value; final ValueChanged onChanged; const _DatePane( {super.key, this.title, required this.value, required this.onChanged}); @override State<_DatePane> createState() => _DatePaneState(); } class _DatePaneState extends State<_DatePane> { 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); _setText(n ?? formatYmd(_wheelAnchor)); if (n != null) _applyTyped(n); } if (mounted) setState(() {}); }); } @override void dispose() { _ctrl.dispose(); _focus.dispose(); super.dispose(); } void _setText(String v) { _syncing = true; _ctrl.text = v; _syncing = false; } 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 SizedBox( width: _kWidth, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (widget.title != null) Padding( padding: const EdgeInsets.only(left: 2, bottom: 6), child: Text(widget.title!, style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w600, color: t.muted)), ), // 输入框:与滚轮同宽、文字水平垂直居中、mono、扁平 Container( height: 34, 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), ), child: Center( child: TextField( controller: _ctrl, focusNode: _focus, keyboardType: TextInputType.datetime, textAlign: TextAlign.center, // 强制 strut 统一行盒(等宽字体 ascent/descent 不对称会让文字 // 视觉偏离垂直中心;height:1 行高=字号,Center 精确居中) strutStyle: const StrutStyle( fontSize: AppDims.fsBody, height: 1.0, forceStrutHeight: true, ), style: TextStyle( fontSize: AppDims.fsBody, height: 1.0, 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) { _setText(n); _applyTyped(n); } }, ), ), ), _WheelCols( key: ValueKey(formatYmd(_wheelAnchor)), initial: _wheelAnchor, onChanged: (d) { widget.onChanged(d); _setText(formatYmd(d)); }, ), ], ), ); } } // ═══════════════ 三列滚轮(扁平;对齐原型 datewheel.js 三列结构)═══════════════ class _WheelCols extends StatefulWidget { final DateTime initial; final ValueChanged onChanged; const _WheelCols({super.key, required this.initial, required this.onChanged}); @override State<_WheelCols> createState() => _WheelColsState(); } class _WheelColsState extends State<_WheelCols> { 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 _emit() => widget.onChanged(DateTime(_year, _month, _dIdx + 1)); // 年/月变化后重算当月天数,超界则回夹并吸附。 void _onYearOrMonth() { final n = _daysIn(_year, _month); if (_dIdx > n - 1) { _dIdx = n - 1; _dCtrl.jumpToItem(_dIdx); } setState(() {}); _emit(); } @override Widget build(BuildContext context) { final t = context.tokens; final days = _daysIn(_year, _month); // 扁平:仅 1px 边框 + 圆角,无阴影/立体 return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rMd), ), child: 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), ), ), ), ), 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); _emit(); }), ], ), ], ), ), ); } 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, ), ), ); }, ), ), ); } }