refactor(client): 日期选择统一为扁平组件——输入框+滚轮同宽居中入面板,桌面/移动全覆盖

- wheel_date_picker.dart 重构为统一组件:单日期 showDsDateDropdown(面板=
  可输入框+三列滚轮+今天/确定)、范围 showDateRangeDropdown(起始|结束双窗格
  并排+共用底栏);桌面锚定下拉(透明遮罩不冻结窗口)、窄屏底部 sheet 同内容
  (范围纵排)——同一套窗格两端复用
- 扁平化:输入框与滚轮同宽 248、文字水平垂直居中(mono)、去掉全部阴影/立体
  效果(滚轮外框仅 1px 边框,中心高亮带去描边)
- DsDateCell(录单日期格)与 DatePickerField(登记收支)改为触发器:点选/
  回车打开统一面板,键入在面板内完成;出库工具栏时间 chip 补切到新组件
- 旧 showWheelDatePicker/showWheelDateRange 两步弹窗删除;组件测试改写为
  新契约(触发器+面板键入+词法归一),表单 golden 随形态更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-04 17:49:17 +08:00
parent 06dc68245a
commit fb236018f3
17 changed files with 687 additions and 807 deletions
+71 -184
View File
@@ -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<String?> onChanged;
@@ -789,198 +788,86 @@ class DsDateCell extends StatefulWidget {
}
class _DsDateCellState extends State<DsDateCell> {
// 浮层锚定到字段下方(对齐原型 datewheel.jspop.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<void> _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),
]),
),
);
}),
);
}
}