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),
]),
),
);
}),
);
}
}
@@ -228,8 +228,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
n.setDetail(const {});
}
Future<void> _pickDateRange() async {
final range = await showWheelDateRange(context, initial: _dateRange);
Future<void> _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<StockOutListScreen> {
}
/// 出库时间预设(对齐原型:全部时间/近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<StockOutListScreen> {
},
);
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(
+57 -81
View File
@@ -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<String?> onChanged;
@@ -28,55 +28,12 @@ class DatePickerField extends StatefulWidget {
}
class _DatePickerFieldState extends State<DatePickerField> {
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<void> _pickFromCalendar(FormFieldState<String> 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<void> _pick(
BuildContext anchorCtx, FormFieldState<String> 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<DatePickerField> {
@override
Widget build(BuildContext context) {
final t = context.tokens;
return FormField<String>(
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)),
),
],
);
},
);
File diff suppressed because it is too large Load Diff
+35 -21
View File
@@ -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);
});
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB