diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 098d468..5f6f05f 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -198,8 +198,8 @@ class _StockInListScreenState extends ConsumerState { n.setDetail(const {}); } - Future _pickDateRange() async { - final range = await showWheelDateRange(context, initial: _dateRange); + Future _pickDateRange(BuildContext anchor) async { + final range = await showDateRangeDropdown(anchor, initial: _dateRange); if (range != null) { setState(() { _dateRange = range; @@ -210,9 +210,9 @@ class _StockInListScreenState extends ConsumerState { } /// 入库时间预设(对齐原型:全部时间/近7天/近30天/本月/自定义)。 - void _setDatePreset(String preset) { + void _setDatePreset(BuildContext anchor, String preset) { if (preset == 'custom') { - _pickDateRange(); + _pickDateRange(anchor); return; } final now = DateTime.now(); @@ -245,26 +245,29 @@ class _StockInListScreenState extends ConsumerState { ref.read(stockInListProvider.notifier).setDateRange(_startDate, _endDate); } - Widget _dateMenu(BuildContext ctx) => PopupMenuButton( - onSelected: _setDatePreset, - offset: const Offset(0, 40), - color: ctx.tokens.surface, - elevation: 8, - shape: RoundedRectangleBorder( - side: BorderSide(color: ctx.tokens.border), - borderRadius: BorderRadius.circular(AppDims.rMd), + Widget _dateMenu(BuildContext ctx) => Builder( + // anchorCtx = chip 自身位置:自定义范围下拉锚定在 chip 下方 + builder: (anchorCtx) => PopupMenuButton( + onSelected: (v) => _setDatePreset(anchorCtx, v), + offset: const Offset(0, 40), + color: ctx.tokens.surface, + elevation: 8, + shape: RoundedRectangleBorder( + side: BorderSide(color: ctx.tokens.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + itemBuilder: (_) => const [ + PopupMenuItem(value: 'all', height: 38, child: Text('全部时间')), + PopupMenuItem(value: '7', height: 38, child: Text('近 7 天')), + PopupMenuItem(value: '30', height: 38, child: Text('近 30 天')), + PopupMenuItem(value: 'month', height: 38, child: Text('本月')), + PopupMenuItem(value: 'custom', height: 38, child: Text('自定义…')), + ], + child: DsChip( + label: '入库时间', + value: _datePresetLabel.isEmpty ? null : _datePresetLabel, + onClear: () => _setDatePreset(anchorCtx, 'all')), ), - itemBuilder: (_) => const [ - PopupMenuItem(value: 'all', height: 38, child: Text('全部时间')), - PopupMenuItem(value: '7', height: 38, child: Text('近 7 天')), - PopupMenuItem(value: '30', height: 38, child: Text('近 30 天')), - PopupMenuItem(value: 'month', height: 38, child: Text('本月')), - PopupMenuItem(value: 'custom', height: 38, child: Text('自定义…')), - ], - child: DsChip( - label: '入库时间', - value: _datePresetLabel.isEmpty ? null : _datePresetLabel, - onClear: () => _setDatePreset('all')), ); Future _openAdvSearch() async { @@ -1649,8 +1652,8 @@ class _AdvSearchDialogState extends ConsumerState<_AdvSearchDialog> { Navigator.of(context).pop(_AdvResult(d, _status, _range)); } - Future _pickDate() async { - final range = await showWheelDateRange(context, initial: _range); + Future _pickDate(BuildContext anchor) async { + final range = await showDateRangeDropdown(anchor, initial: _range); if (range != null) setState(() => _range = range); } @@ -1930,33 +1933,35 @@ class _AdvSearchDialogState extends ConsumerState<_AdvSearchDialog> { final label = has ? '${_range!.start.toString().substring(0, 10)} ~ ${_range!.end.toString().substring(0, 10)}' : '全部时间'; - return InkWell( - onTap: _pickDate, - borderRadius: BorderRadius.circular(AppDims.rMd), - child: Container( - height: 38, - padding: const EdgeInsets.symmetric(horizontal: 11), - decoration: BoxDecoration( - color: t.surface, - border: Border.all(color: t.border), - borderRadius: BorderRadius.circular(AppDims.rMd), - ), - child: Row(children: [ - Expanded( - child: Text(label, - style: TextStyle( - fontSize: AppDims.fsBody, color: has ? t.text : t.faint)), - ), - if (has) - GestureDetector( - onTap: () => setState(() => _range = null), - child: Icon(LucideIcons.x, size: 14, color: t.muted), - ) - else - Icon(LucideIcons.calendar, size: 16, color: t.faint), - ]), - ), - ); + return Builder( + builder: (anchorCtx) => InkWell( + onTap: () => _pickDate(anchorCtx), + borderRadius: BorderRadius.circular(AppDims.rMd), + child: Container( + height: 38, + padding: const EdgeInsets.symmetric(horizontal: 11), + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row(children: [ + Expanded( + child: Text(label, + style: TextStyle( + fontSize: AppDims.fsBody, + color: has ? t.text : t.faint)), + ), + if (has) + GestureDetector( + onTap: () => setState(() => _range = null), + child: Icon(LucideIcons.x, size: 14, color: t.muted), + ) + else + Icon(LucideIcons.calendar, size: 16, color: t.faint), + ]), + ), + )); } } @@ -2055,8 +2060,8 @@ class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> { Navigator.of(context).pop(_AdvResult(d, _status, _range)); } - Future _pickDate() async { - final range = await showWheelDateRange(context, initial: _range); + Future _pickDate(BuildContext anchor) async { + final range = await showDateRangeDropdown(anchor, initial: _range); if (range != null) setState(() => _range = range); } @@ -2171,37 +2176,39 @@ class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> { final statusSelIdx = _statusCodes.indexOf(_status); final dateHas = _range != null; - final dateField = InkWell( - onTap: _pickDate, - borderRadius: BorderRadius.circular(AppDims.rMd), - child: Container( - height: 42, - padding: const EdgeInsets.symmetric(horizontal: 11), - decoration: BoxDecoration( - color: t.surface, - border: Border.all(color: t.border), - borderRadius: BorderRadius.circular(AppDims.rMd), - ), - child: Row(children: [ - Expanded( - child: Text( - dateHas - ? '${_range!.start.toString().substring(0, 10)} ~ ${_range!.end.toString().substring(0, 10)}' - : '全部时间', - style: TextStyle( - fontSize: AppDims.fsBody, color: dateHas ? t.text : t.faint), - ), - ), - if (dateHas) - GestureDetector( - onTap: () => setState(() => _range = null), - child: Icon(LucideIcons.x, size: 14, color: t.muted), - ) - else - Icon(LucideIcons.calendar, size: 16, color: t.faint), - ]), - ), - ); + final dateField = Builder( + builder: (anchorCtx) => InkWell( + onTap: () => _pickDate(anchorCtx), + borderRadius: BorderRadius.circular(AppDims.rMd), + child: Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 11), + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row(children: [ + Expanded( + child: Text( + dateHas + ? '${_range!.start.toString().substring(0, 10)} ~ ${_range!.end.toString().substring(0, 10)}' + : '全部时间', + style: TextStyle( + fontSize: AppDims.fsBody, + color: dateHas ? t.text : t.faint), + ), + ), + if (dateHas) + GestureDetector( + onTap: () => setState(() => _range = null), + child: Icon(LucideIcons.x, size: 14, color: t.muted), + ) + else + Icon(LucideIcons.calendar, size: 16, color: t.faint), + ]), + ), + )); return Column( mainAxisSize: MainAxisSize.min, diff --git a/client/lib/screens/stock_out/stock_out_list_screen.dart b/client/lib/screens/stock_out/stock_out_list_screen.dart index 95e81e5..ba6c6ed 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -1713,8 +1713,8 @@ class _AdvancedSearchDialogState extends ConsumerState<_AdvancedSearchDialog> { )); } - Future _pickDate() async { - final range = await showWheelDateRange(context, initial: _dateRange); + Future _pickDate(BuildContext anchor) async { + final range = await showDateRangeDropdown(anchor, initial: _dateRange); if (range != null) setState(() => _dateRange = range); } @@ -1838,38 +1838,39 @@ class _AdvancedSearchDialogState extends ConsumerState<_AdvancedSearchDialog> { } final dateHas = _dateRange != null; - Widget dateField() => InkWell( - onTap: _pickDate, - borderRadius: BorderRadius.circular(AppDims.rMd), - child: Container( - height: 38, - padding: const EdgeInsets.symmetric(horizontal: 11), - decoration: BoxDecoration( - color: t.surface, - border: Border.all(color: t.border), + Widget dateField() => Builder( + builder: (anchorCtx) => InkWell( + onTap: () => _pickDate(anchorCtx), borderRadius: BorderRadius.circular(AppDims.rMd), - ), - child: Row(children: [ - Expanded( - child: Text( - dateHas - ? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}' - : '全部时间', - style: TextStyle( - fontSize: AppDims.fsBody, - color: dateHas ? t.text : t.faint), + child: Container( + height: 38, + padding: const EdgeInsets.symmetric(horizontal: 11), + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), ), + child: Row(children: [ + Expanded( + child: Text( + dateHas + ? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}' + : '全部时间', + style: TextStyle( + fontSize: AppDims.fsBody, + color: dateHas ? t.text : t.faint), + ), + ), + if (dateHas) + GestureDetector( + onTap: () => setState(() => _dateRange = null), + child: Icon(LucideIcons.x, size: 14, color: t.muted), + ) + else + Icon(LucideIcons.calendar, size: 16, color: t.faint), + ]), ), - if (dateHas) - GestureDetector( - onTap: () => setState(() => _dateRange = null), - child: Icon(LucideIcons.x, size: 14, color: t.muted), - ) - else - Icon(LucideIcons.calendar, size: 16, color: t.faint), - ]), - ), - ); + )); return AlertDialog( backgroundColor: t.surface, @@ -2135,8 +2136,8 @@ class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> { )); } - Future _pickDate() async { - final range = await showWheelDateRange(context, initial: _dateRange); + Future _pickDate(BuildContext anchor) async { + final range = await showDateRangeDropdown(anchor, initial: _dateRange); if (range != null) setState(() => _dateRange = range); } @@ -2225,37 +2226,39 @@ class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> { final statusSelIdx = _statusCodes.indexOf(_status); final dateHas = _dateRange != null; - final dateField = InkWell( - onTap: _pickDate, - borderRadius: BorderRadius.circular(AppDims.rMd), - child: Container( - height: 42, - padding: const EdgeInsets.symmetric(horizontal: 11), - decoration: BoxDecoration( - color: t.surface, - border: Border.all(color: t.border), - borderRadius: BorderRadius.circular(AppDims.rMd), - ), - child: Row(children: [ - Expanded( - child: Text( - dateHas - ? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}' - : '全部时间', - style: TextStyle( - fontSize: AppDims.fsBody, color: dateHas ? t.text : t.faint), - ), - ), - if (dateHas) - GestureDetector( - onTap: () => setState(() => _dateRange = null), - child: Icon(LucideIcons.x, size: 14, color: t.muted), - ) - else - Icon(LucideIcons.calendar, size: 16, color: t.faint), - ]), - ), - ); + final dateField = Builder( + builder: (anchorCtx) => InkWell( + onTap: () => _pickDate(anchorCtx), + borderRadius: BorderRadius.circular(AppDims.rMd), + child: Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 11), + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row(children: [ + Expanded( + child: Text( + dateHas + ? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}' + : '全部时间', + style: TextStyle( + fontSize: AppDims.fsBody, + color: dateHas ? t.text : t.faint), + ), + ), + if (dateHas) + GestureDetector( + onTap: () => setState(() => _dateRange = null), + child: Icon(LucideIcons.x, size: 14, color: t.muted), + ) + else + Icon(LucideIcons.calendar, size: 16, color: t.faint), + ]), + ), + )); return Column( mainAxisSize: MainAxisSize.min, diff --git a/client/lib/widgets/wheel_date_picker.dart b/client/lib/widgets/wheel_date_picker.dart index c504ae7..f66f9db 100644 --- a/client/lib/widgets/wheel_date_picker.dart +++ b/client/lib/widgets/wheel_date_picker.dart @@ -1,8 +1,12 @@ +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'; @@ -20,11 +24,19 @@ class WheelDatePanel extends StatefulWidget { /// 可选标题(范围选择用「起始/结束日期」区分);空则不渲染(对齐原型无标题)。 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 @@ -78,6 +90,7 @@ class _WheelDatePanelState extends State { _dCtrl.jumpToItem(_dIdx); } setState(() {}); + widget.onChanged?.call(DateTime(_year, _month, _dIdx + 1)); } void _goToday() { @@ -157,55 +170,59 @@ class _WheelDatePanelState extends State { _mIdx = i; _onYearOrMonth(); }), - _col(t, _dCtrl, days, (i) => _pad2(i + 1), _dIdx, - (i) => setState(() => _dIdx = i)), + _col(t, _dCtrl, days, (i) => _pad2(i + 1), _dIdx, (i) { + setState(() => _dIdx = i); + widget.onChanged + ?.call(DateTime(_year, _month, _dIdx + 1)); + }), ], ), ], ), ), // 底栏:今天(左) / 确定(右,无取消,点外部取消) - 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), + 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)), ), - child: Text('确定', - style: TextStyle( - fontSize: AppDims.fsSm, - color: t.onPrimary, - fontWeight: FontWeight.w600)), ), - ), - ], + 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)), + ), + ), + ], + ), ), - ), ], ), ), @@ -285,3 +302,320 @@ Future showWheelDateRange( ? 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; + }, + ), + ], + ); + } +}