diff --git a/client/lib/screens/shared/order_form_shell.dart b/client/lib/screens/shared/order_form_shell.dart index 3e5ef85..2f05ada 100644 --- a/client/lib/screens/shared/order_form_shell.dart +++ b/client/lib/screens/shared/order_form_shell.dart @@ -760,9 +760,8 @@ class RowActions extends StatelessWidget { ); } -/// 内联日期格(.datefield / .gci-date):可直接键入(「2024」「2024-5-12」 -/// 「20240512」等,失焦/回车归一为 yyyy-MM-dd,同 DatePickerField 词法), -/// 日历图标打开滚轮面板;值等宽显示。 +/// 内联日期格(.datefield / .gci-date):点选或 Enter 打开统一日期下拉 +/// (面板内含可输入框 + 三列滚轮,见 wheel_date_picker.dart),值等宽显示。 class DsDateCell extends StatefulWidget { final String? value; // yyyy-MM-dd final ValueChanged onChanged; @@ -789,198 +788,86 @@ class DsDateCell extends StatefulWidget { } class _DsDateCellState extends State { - // 浮层锚定到字段下方(对齐原型 datewheel.js:pop.style.top = field.bottom + 4), - // 非居中弹窗,无遮罩——点字段外任意处收起(TapRegion.onTapOutside)。 - final _link = LayerLink(); - OverlayEntry? _entry; - bool get _open => _entry != null; + BuildContext? _anchorCtx; // 字段自身 ctx(Builder 捕获),下拉锚定于此 - late final TextEditingController _ctrl = - TextEditingController(text: widget.value ?? ''); - FocusNode? _ownFocus; // 调用方没给 focusNode 时自建(自建才由本组件 dispose) - FocusNode get _focus => widget.focusNode ?? (_ownFocus ??= FocusNode()); - - @override - void initState() { - super.initState(); - _focus.addListener(_onFocusChange); - } - - void _onFocusChange() { - // 失焦归一显示(2024 → 2024-01-01);顺带刷新激活描边 - if (!_focus.hasFocus) { - final n = normalizeYmd(_ctrl.text); - if (n != null && n != _ctrl.text) _ctrl.text = n; + Future _pick() async { + final anchor = _anchorCtx; + if (!widget.enabled || anchor == null) return; + final d = await showDsDateDropdown(anchor, initial: parseYmd(widget.value)); + if (d != null) { + widget.onChanged(formatYmd(d)); + widget.onPicked?.call(); } - if (mounted) setState(() {}); - } - - @override - void didUpdateWidget(DsDateCell old) { - super.didUpdateWidget(old); - // 仅在无焦点(非用户正在输入)时同步外部值,避免回流打断输入 - if (!_focus.hasFocus && - old.value != widget.value && - (widget.value ?? '') != _ctrl.text) { - _ctrl.text = widget.value ?? ''; - } - } - - @override - void dispose() { - _removeOverlay(); - _focus.removeListener(_onFocusChange); - _ownFocus?.dispose(); - _ctrl.dispose(); - super.dispose(); - } - - void _toggle() { - if (_open) { - _close(); - } else { - _openOverlay(); - } - } - - void _openOverlay() { - if (_open || !widget.enabled) return; - final init = parseYmd(widget.value) ?? DateTime.now(); - _entry = OverlayEntry( - builder: (_) => Positioned( - width: 248, - child: CompositedTransformFollower( - link: _link, - showWhenUnlinked: false, - targetAnchor: Alignment.bottomLeft, - followerAnchor: Alignment.topLeft, - offset: const Offset(0, 4), - child: TapRegion( - onTapOutside: (_) => _close(), - child: WheelDatePanel( - initial: init, - onCommit: (d) { - final v = formatYmd(d); - _ctrl.text = v; - widget.onChanged(v); - _close(); - widget.onPicked?.call(); - }, - ), - ), - ), - ), - ); - Overlay.of(context).insert(_entry!); - setState(() {}); - } - - void _close() { - _removeOverlay(); - if (mounted) setState(() {}); - } - - void _removeOverlay() { - _entry?.remove(); - _entry = null; - } - - /// 提交键入值:归一 + 回写 + 推进下一格(Enter / 失焦走这里)。 - void _commitTyped(String v) { - final n = normalizeYmd(v); - if (n != null) _ctrl.text = n; - widget.onChanged(n); - if (_open) _close(); - widget.onPicked?.call(); } @override Widget build(BuildContext context) { final t = context.tokens; + final has = widget.value != null && widget.value!.isNotEmpty; final height = widget.cell ? 32.0 : 38.0; - final active = _focus.hasFocus || _open; - return CompositedTransformTarget( - link: _link, - child: Focus( - // Tab/Esc 仍拦在外层(键盘流推进 / 收滚轮);Enter 交给 TextField.onSubmitted - onKeyEvent: (node, e) { - if (e is! KeyDownEvent) return KeyEventResult.ignored; - final k = e.logicalKey; - if (k == LogicalKeyboardKey.escape) { - if (_open) { - _close(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - if (k == LogicalKeyboardKey.tab) { - final back = HardwareKeyboard.instance.isShiftPressed; - if (_open) _close(); - final handled = widget.onTab?.call(backward: back) ?? false; - return handled ? KeyEventResult.handled : KeyEventResult.ignored; - } - return KeyEventResult.ignored; - }, - child: Container( - height: height, - padding: const EdgeInsets.only(left: 10, right: 6), - decoration: BoxDecoration( - color: widget.cell - ? (active ? t.surface : Colors.transparent) - : t.surface, - border: Border.all( - color: widget.hasError - ? t.danger - : (active - ? t.primary - : (widget.cell ? Colors.transparent : t.border)), + return Focus( + focusNode: widget.focusNode, + onKeyEvent: (node, e) { + if (e is! KeyDownEvent) return KeyEventResult.ignored; + final k = e.logicalKey; + if (k == LogicalKeyboardKey.enter || + k == LogicalKeyboardKey.numpadEnter || + k == LogicalKeyboardKey.space) { + _pick(); + return KeyEventResult.handled; + } + if (k == LogicalKeyboardKey.tab) { + final back = HardwareKeyboard.instance.isShiftPressed; + final handled = widget.onTab?.call(backward: back) ?? false; + return handled ? KeyEventResult.handled : KeyEventResult.ignored; + } + return KeyEventResult.ignored; + }, + child: Builder(builder: (context) { + _anchorCtx = context; + final active = Focus.of(context).hasFocus; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.enabled + ? () { + widget.focusNode?.requestFocus(); + _pick(); + } + : null, + child: Container( + height: height, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: widget.cell + ? (active ? t.surface : Colors.transparent) + : t.surface, + border: Border.all( + color: widget.hasError + ? t.danger + : (active + ? t.primary + : (widget.cell ? Colors.transparent : t.border)), + ), + borderRadius: + BorderRadius.circular(widget.cell ? 5 : AppDims.rMd), ), - borderRadius: BorderRadius.circular(widget.cell ? 5 : AppDims.rMd), - ), - child: Row(children: [ - Expanded( - child: TextField( - controller: _ctrl, - focusNode: _focus, - enabled: widget.enabled, - keyboardType: TextInputType.datetime, - style: TextStyle( - fontSize: AppDims.fsBody, - color: t.text, - fontFamily: AppFonts.mono, - fontFamilyFallback: AppFonts.monoFallback), - decoration: InputDecoration( - isCollapsed: true, - isDense: true, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - disabledBorder: InputBorder.none, - filled: false, - hintText: '年-月-日', - hintStyle: - TextStyle(fontSize: AppDims.fsBody, color: t.faint), + child: Row(children: [ + Expanded( + child: Text( + has ? widget.value! : '选择日期', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppDims.fsBody, + color: has ? t.text : t.faint, + fontFamily: has ? AppFonts.mono : null, + fontFamilyFallback: has ? AppFonts.monoFallback : null), ), - onChanged: (v) => widget.onChanged(normalizeYmd(v)), - onSubmitted: _commitTyped, ), - ), - InkWell( - onTap: widget.enabled - ? () { - _focus.requestFocus(); - _toggle(); - } - : null, - borderRadius: BorderRadius.circular(AppDims.rSm), - child: Padding( - padding: const EdgeInsets.all(4), - child: Icon(LucideIcons.calendar, size: 15, color: t.faint), - ), - ), - ]), - ), - ), + Icon(LucideIcons.calendar, size: 15, color: t.faint), + ]), + ), + ); + }), ); } } 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 ba6c6ed..d9d6734 100644 --- a/client/lib/screens/stock_out/stock_out_list_screen.dart +++ b/client/lib/screens/stock_out/stock_out_list_screen.dart @@ -228,8 +228,8 @@ class _StockOutListScreenState 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) return; setState(() { _dateRange = range; @@ -240,9 +240,9 @@ class _StockOutListScreenState extends ConsumerState { } /// 出库时间预设(对齐原型:全部时间/近7天/近30天/本月/自定义)。 - void _setDatePreset(String label) { + void _setDatePreset(BuildContext anchor, String label) { if (label == '自定义…') { - _pickDateRange(); + _pickDateRange(anchor); return; } final now = DateTime.now(); @@ -871,12 +871,15 @@ class _StockOutListScreenState extends ConsumerState { }, ); - final dateChip = _MenuChip( - label: '出库时间', - value: _datePresetLabel.isEmpty ? null : _datePresetLabel, - options: const ['全部时间', '近 7 天', '近 30 天', '本月', '自定义…'], - onSelected: _setDatePreset, - onClear: () => _setDatePreset('全部时间'), + // anchorCtx = chip 自身位置:自定义范围下拉锚定在 chip 下方 + final dateChip = Builder( + builder: (anchorCtx) => _MenuChip( + label: '出库时间', + value: _datePresetLabel.isEmpty ? null : _datePresetLabel, + options: const ['全部时间', '近 7 天', '近 30 天', '本月', '自定义…'], + onSelected: (v) => _setDatePreset(anchorCtx, v), + onClear: () => _setDatePreset(anchorCtx, '全部时间'), + ), ); final advBtn = DsButton( diff --git a/client/lib/widgets/date_picker_field.dart b/client/lib/widgets/date_picker_field.dart index 784e127..3b90271 100644 --- a/client/lib/widgets/date_picker_field.dart +++ b/client/lib/widgets/date_picker_field.dart @@ -1,14 +1,14 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/material.dart'; +import '../core/theme/app_dims.g.dart'; +import '../core/theme/app_fonts.dart'; import '../core/theme/context_tokens.dart'; import '../core/utils/date_util.dart'; +import 'wheel_date_picker.dart'; -/// 可键入日期框 + 日历图标(替代旧的「年/月/日」三下拉)。 -/// -/// - **直接键入**:支持「2024」「2024-5」「2024-5-12」「20240512」等,归一为 `yyyy-MM-dd`, -/// 缺的月/日补 `01`(老酒只知道年份,填 2024 即 2024-01-01)。失焦时把显示归一化。 -/// - **日历图标**:弹 Material `showDatePicker`(input 模式可直接键入年份,选老年份方便)。 -/// - 值以 `yyyy-MM-dd` 字符串进出([value] / [onChanged]);无有效年时回调 null。 +/// 日期字段(登记收支等表单用):点选打开统一日期下拉(面板内含可输入框 + +/// 三列滚轮,见 wheel_date_picker.dart,桌面锚定下拉 / 窄屏底部 sheet)。 +/// 值以 `yyyy-MM-dd` 字符串进出([value] / [onChanged])。 class DatePickerField extends StatefulWidget { final String? value; // yyyy-MM-dd final ValueChanged onChanged; @@ -28,55 +28,12 @@ class DatePickerField extends StatefulWidget { } class _DatePickerFieldState extends State { - late final TextEditingController _ctrl; - final FocusNode _focus = FocusNode(); - - @override - void initState() { - super.initState(); - _ctrl = TextEditingController(text: widget.value ?? ''); - _focus.addListener(() { - // 失焦时把输入归一化显示(如 2024 → 2024-01-01) - if (!_focus.hasFocus) { - final n = normalizeYmd(_ctrl.text); - if (n != null && n != _ctrl.text) { - _ctrl.text = n; - } - } - }); - } - - @override - void didUpdateWidget(DatePickerField old) { - super.didUpdateWidget(old); - // 仅在无焦点(非用户正在输入)时同步外部值,避免回流打断输入 - if (!_focus.hasFocus && - old.value != widget.value && - (widget.value ?? '') != _ctrl.text) { - _ctrl.text = widget.value ?? ''; - } - } - - @override - void dispose() { - _ctrl.dispose(); - _focus.dispose(); - super.dispose(); - } - - Future _pickFromCalendar(FormFieldState field) async { - final init = - parseYmd(normalizeYmd(_ctrl.text) ?? widget.value) ?? DateTime.now(); - final picked = await showDatePicker( - context: context, - initialDate: init, - firstDate: DateTime(1900), - lastDate: DateTime(2200), - initialEntryMode: DatePickerEntryMode.input, - ); - if (picked != null) { - final v = formatYmd(picked); - _ctrl.text = v; + Future _pick( + BuildContext anchorCtx, FormFieldState field) async { + final d = + await showDsDateDropdown(anchorCtx, initial: parseYmd(widget.value)); + if (d != null) { + final v = formatYmd(d); field.didChange(v); widget.onChanged(v); } @@ -84,38 +41,57 @@ class _DatePickerFieldState extends State { @override Widget build(BuildContext context) { + final t = context.tokens; return FormField( initialValue: widget.value, validator: widget.isRequired - ? (_) => normalizeYmd(_ctrl.text) == null ? '请输入日期' : null + ? (_) => + (widget.value == null || widget.value!.isEmpty) ? '请输入日期' : null : null, builder: (field) { - return TextField( - controller: _ctrl, - focusNode: _focus, - keyboardType: TextInputType.datetime, - style: const TextStyle(fontSize: 13), - decoration: InputDecoration( - isDense: true, - hintText: '年-月-日', - hintStyle: const TextStyle(fontSize: 12), - contentPadding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - errorText: field.errorText, - suffixIcon: IconButton( - icon: Icon(LucideIcons.calendar, - size: 16, color: context.tokens.muted), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 34, minHeight: 34), - tooltip: '选择日期', - onPressed: () => _pickFromCalendar(field), + final has = widget.value != null && widget.value!.isNotEmpty; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Builder( + builder: (anchorCtx) => InkWell( + onTap: () => _pick(anchorCtx, field), + borderRadius: BorderRadius.circular(AppDims.rMd), + child: Container( + height: 38, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: t.surface, + border: + Border.all(color: field.hasError ? t.danger : t.border), + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row(children: [ + Expanded( + child: Text( + has ? widget.value! : '年-月-日', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppDims.fsBody, + color: has ? t.text : t.faint, + fontFamily: has ? AppFonts.mono : null, + fontFamilyFallback: + has ? AppFonts.monoFallback : null), + ), + ), + Icon(LucideIcons.calendar, size: 16, color: t.muted), + ]), + ), + ), ), - ), - onChanged: (v) { - final n = normalizeYmd(v); - field.didChange(n); - widget.onChanged(n); - }, + if (field.errorText != null) + Padding( + padding: const EdgeInsets.only(top: 4, left: 2), + child: Text(field.errorText!, + style: TextStyle(fontSize: AppDims.fsXs, color: t.danger)), + ), + ], ); }, ); diff --git a/client/lib/widgets/wheel_date_picker.dart b/client/lib/widgets/wheel_date_picker.dart index f66f9db..c7e86bb 100644 --- a/client/lib/widgets/wheel_date_picker.dart +++ b/client/lib/widgets/wheel_date_picker.dart @@ -1,3 +1,13 @@ +// 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'; @@ -7,50 +17,474 @@ 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(); -} +import 'ds/m_sheet.dart'; const double _kItem = 36; // .wheel-item 行高 const double _kColsH = 180; // .wheel-cols 高度(5 行) -const double _kWidth = 248; // .wheel-pop 宽度 +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 -class _WheelDatePanelState extends State { +// ═══════════════ 对外入口 ═══════════════ + +/// 单日期选择:桌面锚定 [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, + textAlignVertical: TextAlignVertical.center, + 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) { + _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; @@ -82,6 +516,8 @@ class _WheelDatePanelState extends State { super.dispose(); } + void _emit() => widget.onChanged(DateTime(_year, _month, _dIdx + 1)); + // 年/月变化后重算当月天数,超界则回夹并吸附。 void _onYearOrMonth() { final n = _daysIn(_year, _month); @@ -90,139 +526,55 @@ class _WheelDatePanelState extends State { _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(() {}); + _emit(); } @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, + // 扁平:仅 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: [ - 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)), + 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), + ), ), ), - // 三列滚轮 + 中间高亮带 - 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)), - ), - ), - ], - ), - ), + 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(); + }), + ], + ), ], ), ), @@ -267,355 +619,3 @@ class _WheelDatePanelState extends State { ); } } - -/// 居中弹窗形式(范围选择用)。锚定字段的用法见 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; - }, - ), - ], - ); - } -} diff --git a/client/test/date_picker_field_test.dart b/client/test/date_picker_field_test.dart index 2ff1e75..317d5a5 100644 --- a/client/test/date_picker_field_test.dart +++ b/client/test/date_picker_field_test.dart @@ -33,19 +33,7 @@ void main() { }); group('DatePickerField', () { - testWidgets('渲染可键入框 + 日历图标', (tester) async { - await tester.pumpWidget(MaterialApp( - theme: buildTheme('a'), - home: Scaffold( - body: DatePickerField(value: '2026-06-19', onChanged: (_) {}), - ), - )); - await tester.pumpAndSettle(); - expect(find.byType(TextField), findsOneWidget); - expect(find.byIcon(LucideIcons.calendar), findsOneWidget); - }); - - testWidgets('初始值回显到输入框', (tester) async { + testWidgets('渲染触发字段:值回显 + 日历图标', (tester) async { await tester.pumpWidget(MaterialApp( theme: buildTheme('a'), home: Scaffold( @@ -54,30 +42,56 @@ void main() { )); await tester.pumpAndSettle(); expect(find.text('2026-06-19'), findsOneWidget); + expect(find.byIcon(LucideIcons.calendar), findsOneWidget); + // 字段本体不再内嵌输入框(输入框在下拉面板里) + expect(find.byType(TextField), findsNothing); }); - testWidgets('只输年份归一为 yyyy-01-01', (tester) async { + testWidgets('点选打开统一下拉:面板含输入框 + 滚轮,确定回传', (tester) async { String? out; await tester.pumpWidget(MaterialApp( theme: buildTheme('a'), home: Scaffold( - body: DatePickerField(onChanged: (v) => out = v), + body: DatePickerField(value: '2026-06-19', onChanged: (v) => out = v), ), )); - await tester.enterText(find.byType(TextField), '2024'); - expect(out, '2024-01-01'); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.calendar)); + await tester.pumpAndSettle(); + // 面板:可输入框 + 三列滚轮 + 今天/确定 + expect(find.byType(TextField), findsOneWidget); + expect(find.byType(ListWheelScrollView), findsNWidgets(3)); + expect(find.text('今天'), findsOneWidget); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + expect(out, '2026-06-19'); }); - testWidgets('年月归一为 yyyy-MM-01', (tester) async { + testWidgets('面板内键入日期:归一并经确定回传', (tester) async { String? out; await tester.pumpWidget(MaterialApp( theme: buildTheme('a'), home: Scaffold( - body: DatePickerField(onChanged: (v) => out = v), + body: DatePickerField(value: '2026-06-19', onChanged: (v) => out = v), ), )); - await tester.enterText(find.byType(TextField), '2024-5'); - expect(out, '2024-05-01'); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.calendar)); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), '20240512'); + await tester.pumpAndSettle(); + await tester.tap(find.text('确定')); + await tester.pumpAndSettle(); + expect(out, '2024-05-12'); + }); + + testWidgets('normalizeYmd 词法:年/年月补 01', (tester) async { + expect(normalizeYmd('2024'), '2024-01-01'); + expect(normalizeYmd('2024-5'), '2024-05-01'); + expect(normalizeYmd('2024/05/12'), '2024-05-12'); + expect(normalizeYmd('20240229'), '2024-02-29'); // 闰年 + expect(normalizeYmd('20260231'), '2026-02-28'); // 超界 clamp + expect(normalizeYmd('abc'), isNull); }); }); } diff --git a/client/test/golden/goldens/stock_in_form_a.png b/client/test/golden/goldens/stock_in_form_a.png index c0473d1..1446d16 100644 Binary files a/client/test/golden/goldens/stock_in_form_a.png and b/client/test/golden/goldens/stock_in_form_a.png differ diff --git a/client/test/golden/goldens/stock_in_form_b.png b/client/test/golden/goldens/stock_in_form_b.png index 1393513..a393fd1 100644 Binary files a/client/test/golden/goldens/stock_in_form_b.png and b/client/test/golden/goldens/stock_in_form_b.png differ diff --git a/client/test/golden/goldens/stock_in_form_c.png b/client/test/golden/goldens/stock_in_form_c.png index c3450c9..ad2b2c9 100644 Binary files a/client/test/golden/goldens/stock_in_form_c.png and b/client/test/golden/goldens/stock_in_form_c.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_a.png b/client/test/golden/goldens/stock_in_form_mobile_a.png index ddff4dd..840ec98 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_a.png and b/client/test/golden/goldens/stock_in_form_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_b.png b/client/test/golden/goldens/stock_in_form_mobile_b.png index 38a5660..20cef67 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_b.png and b/client/test/golden/goldens/stock_in_form_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_in_form_mobile_c.png b/client/test/golden/goldens/stock_in_form_mobile_c.png index f3d6b3c..1d25980 100644 Binary files a/client/test/golden/goldens/stock_in_form_mobile_c.png and b/client/test/golden/goldens/stock_in_form_mobile_c.png differ diff --git a/client/test/golden/goldens/stock_out_form_a.png b/client/test/golden/goldens/stock_out_form_a.png index f71346f..1455896 100644 Binary files a/client/test/golden/goldens/stock_out_form_a.png and b/client/test/golden/goldens/stock_out_form_a.png differ diff --git a/client/test/golden/goldens/stock_out_form_b.png b/client/test/golden/goldens/stock_out_form_b.png index 6a2188f..d82979a 100644 Binary files a/client/test/golden/goldens/stock_out_form_b.png and b/client/test/golden/goldens/stock_out_form_b.png differ diff --git a/client/test/golden/goldens/stock_out_form_c.png b/client/test/golden/goldens/stock_out_form_c.png index 05e8fac..78128d8 100644 Binary files a/client/test/golden/goldens/stock_out_form_c.png and b/client/test/golden/goldens/stock_out_form_c.png differ diff --git a/client/test/golden/goldens/stock_out_form_mobile_a.png b/client/test/golden/goldens/stock_out_form_mobile_a.png index 2f91ae0..6f7613f 100644 Binary files a/client/test/golden/goldens/stock_out_form_mobile_a.png and b/client/test/golden/goldens/stock_out_form_mobile_a.png differ diff --git a/client/test/golden/goldens/stock_out_form_mobile_b.png b/client/test/golden/goldens/stock_out_form_mobile_b.png index 90ad7c0..df4c82c 100644 Binary files a/client/test/golden/goldens/stock_out_form_mobile_b.png and b/client/test/golden/goldens/stock_out_form_mobile_b.png differ diff --git a/client/test/golden/goldens/stock_out_form_mobile_c.png b/client/test/golden/goldens/stock_out_form_mobile_c.png index a0197b2..c69609e 100644 Binary files a/client/test/golden/goldens/stock_out_form_mobile_c.png and b/client/test/golden/goldens/stock_out_form_mobile_c.png differ