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>
@@ -760,9 +760,8 @@ class RowActions extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 内联日期格(.datefield / .gci-date):可直接键入(「2024」「2024-5-12」
|
/// 内联日期格(.datefield / .gci-date):点选或 Enter 打开统一日期下拉
|
||||||
/// 「20240512」等,失焦/回车归一为 yyyy-MM-dd,同 DatePickerField 词法),
|
/// (面板内含可输入框 + 三列滚轮,见 wheel_date_picker.dart),值等宽显示。
|
||||||
/// 日历图标打开滚轮面板;值等宽显示。
|
|
||||||
class DsDateCell extends StatefulWidget {
|
class DsDateCell extends StatefulWidget {
|
||||||
final String? value; // yyyy-MM-dd
|
final String? value; // yyyy-MM-dd
|
||||||
final ValueChanged<String?> onChanged;
|
final ValueChanged<String?> onChanged;
|
||||||
@@ -789,198 +788,86 @@ class DsDateCell extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DsDateCellState extends State<DsDateCell> {
|
class _DsDateCellState extends State<DsDateCell> {
|
||||||
// 浮层锚定到字段下方(对齐原型 datewheel.js:pop.style.top = field.bottom + 4),
|
BuildContext? _anchorCtx; // 字段自身 ctx(Builder 捕获),下拉锚定于此
|
||||||
// 非居中弹窗,无遮罩——点字段外任意处收起(TapRegion.onTapOutside)。
|
|
||||||
final _link = LayerLink();
|
|
||||||
OverlayEntry? _entry;
|
|
||||||
bool get _open => _entry != null;
|
|
||||||
|
|
||||||
late final TextEditingController _ctrl =
|
Future<void> _pick() async {
|
||||||
TextEditingController(text: widget.value ?? '');
|
final anchor = _anchorCtx;
|
||||||
FocusNode? _ownFocus; // 调用方没给 focusNode 时自建(自建才由本组件 dispose)
|
if (!widget.enabled || anchor == null) return;
|
||||||
FocusNode get _focus => widget.focusNode ?? (_ownFocus ??= FocusNode());
|
final d = await showDsDateDropdown(anchor, initial: parseYmd(widget.value));
|
||||||
|
if (d != null) {
|
||||||
@override
|
widget.onChanged(formatYmd(d));
|
||||||
void initState() {
|
widget.onPicked?.call();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.tokens;
|
final t = context.tokens;
|
||||||
|
final has = widget.value != null && widget.value!.isNotEmpty;
|
||||||
final height = widget.cell ? 32.0 : 38.0;
|
final height = widget.cell ? 32.0 : 38.0;
|
||||||
final active = _focus.hasFocus || _open;
|
return Focus(
|
||||||
return CompositedTransformTarget(
|
focusNode: widget.focusNode,
|
||||||
link: _link,
|
onKeyEvent: (node, e) {
|
||||||
child: Focus(
|
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
||||||
// Tab/Esc 仍拦在外层(键盘流推进 / 收滚轮);Enter 交给 TextField.onSubmitted
|
final k = e.logicalKey;
|
||||||
onKeyEvent: (node, e) {
|
if (k == LogicalKeyboardKey.enter ||
|
||||||
if (e is! KeyDownEvent) return KeyEventResult.ignored;
|
k == LogicalKeyboardKey.numpadEnter ||
|
||||||
final k = e.logicalKey;
|
k == LogicalKeyboardKey.space) {
|
||||||
if (k == LogicalKeyboardKey.escape) {
|
_pick();
|
||||||
if (_open) {
|
return KeyEventResult.handled;
|
||||||
_close();
|
}
|
||||||
return KeyEventResult.handled;
|
if (k == LogicalKeyboardKey.tab) {
|
||||||
}
|
final back = HardwareKeyboard.instance.isShiftPressed;
|
||||||
return KeyEventResult.ignored;
|
final handled = widget.onTab?.call(backward: back) ?? false;
|
||||||
}
|
return handled ? KeyEventResult.handled : KeyEventResult.ignored;
|
||||||
if (k == LogicalKeyboardKey.tab) {
|
}
|
||||||
final back = HardwareKeyboard.instance.isShiftPressed;
|
return KeyEventResult.ignored;
|
||||||
if (_open) _close();
|
},
|
||||||
final handled = widget.onTab?.call(backward: back) ?? false;
|
child: Builder(builder: (context) {
|
||||||
return handled ? KeyEventResult.handled : KeyEventResult.ignored;
|
_anchorCtx = context;
|
||||||
}
|
final active = Focus.of(context).hasFocus;
|
||||||
return KeyEventResult.ignored;
|
return GestureDetector(
|
||||||
},
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Container(
|
onTap: widget.enabled
|
||||||
height: height,
|
? () {
|
||||||
padding: const EdgeInsets.only(left: 10, right: 6),
|
widget.focusNode?.requestFocus();
|
||||||
decoration: BoxDecoration(
|
_pick();
|
||||||
color: widget.cell
|
}
|
||||||
? (active ? t.surface : Colors.transparent)
|
: null,
|
||||||
: t.surface,
|
child: Container(
|
||||||
border: Border.all(
|
height: height,
|
||||||
color: widget.hasError
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
? t.danger
|
decoration: BoxDecoration(
|
||||||
: (active
|
color: widget.cell
|
||||||
? t.primary
|
? (active ? t.surface : Colors.transparent)
|
||||||
: (widget.cell ? Colors.transparent : t.border)),
|
: 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: Row(children: [
|
child: Text(
|
||||||
Expanded(
|
has ? widget.value! : '选择日期',
|
||||||
child: TextField(
|
overflow: TextOverflow.ellipsis,
|
||||||
controller: _ctrl,
|
style: TextStyle(
|
||||||
focusNode: _focus,
|
fontSize: AppDims.fsBody,
|
||||||
enabled: widget.enabled,
|
color: has ? t.text : t.faint,
|
||||||
keyboardType: TextInputType.datetime,
|
fontFamily: has ? AppFonts.mono : null,
|
||||||
style: TextStyle(
|
fontFamilyFallback: has ? AppFonts.monoFallback : null),
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
onChanged: (v) => widget.onChanged(normalizeYmd(v)),
|
|
||||||
onSubmitted: _commitTyped,
|
|
||||||
),
|
),
|
||||||
),
|
Icon(LucideIcons.calendar, size: 15, color: t.faint),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,8 +228,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
n.setDetail(const {});
|
n.setDetail(const {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickDateRange() async {
|
Future<void> _pickDateRange(BuildContext anchor) async {
|
||||||
final range = await showWheelDateRange(context, initial: _dateRange);
|
final range = await showDateRangeDropdown(anchor, initial: _dateRange);
|
||||||
if (range == null) return;
|
if (range == null) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_dateRange = range;
|
_dateRange = range;
|
||||||
@@ -240,9 +240,9 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 出库时间预设(对齐原型:全部时间/近7天/近30天/本月/自定义)。
|
/// 出库时间预设(对齐原型:全部时间/近7天/近30天/本月/自定义)。
|
||||||
void _setDatePreset(String label) {
|
void _setDatePreset(BuildContext anchor, String label) {
|
||||||
if (label == '自定义…') {
|
if (label == '自定义…') {
|
||||||
_pickDateRange();
|
_pickDateRange(anchor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
@@ -871,12 +871,15 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
final dateChip = _MenuChip(
|
// anchorCtx = chip 自身位置:自定义范围下拉锚定在 chip 下方
|
||||||
label: '出库时间',
|
final dateChip = Builder(
|
||||||
value: _datePresetLabel.isEmpty ? null : _datePresetLabel,
|
builder: (anchorCtx) => _MenuChip(
|
||||||
options: const ['全部时间', '近 7 天', '近 30 天', '本月', '自定义…'],
|
label: '出库时间',
|
||||||
onSelected: _setDatePreset,
|
value: _datePresetLabel.isEmpty ? null : _datePresetLabel,
|
||||||
onClear: () => _setDatePreset('全部时间'),
|
options: const ['全部时间', '近 7 天', '近 30 天', '本月', '自定义…'],
|
||||||
|
onSelected: (v) => _setDatePreset(anchorCtx, v),
|
||||||
|
onClear: () => _setDatePreset(anchorCtx, '全部时间'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final advBtn = DsButton(
|
final advBtn = DsButton(
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:flutter/material.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/theme/context_tokens.dart';
|
||||||
import '../core/utils/date_util.dart';
|
import '../core/utils/date_util.dart';
|
||||||
|
import 'wheel_date_picker.dart';
|
||||||
|
|
||||||
/// 可键入日期框 + 日历图标(替代旧的「年/月/日」三下拉)。
|
/// 日期字段(登记收支等表单用):点选打开统一日期下拉(面板内含可输入框 +
|
||||||
///
|
/// 三列滚轮,见 wheel_date_picker.dart,桌面锚定下拉 / 窄屏底部 sheet)。
|
||||||
/// - **直接键入**:支持「2024」「2024-5」「2024-5-12」「20240512」等,归一为 `yyyy-MM-dd`,
|
/// 值以 `yyyy-MM-dd` 字符串进出([value] / [onChanged])。
|
||||||
/// 缺的月/日补 `01`(老酒只知道年份,填 2024 即 2024-01-01)。失焦时把显示归一化。
|
|
||||||
/// - **日历图标**:弹 Material `showDatePicker`(input 模式可直接键入年份,选老年份方便)。
|
|
||||||
/// - 值以 `yyyy-MM-dd` 字符串进出([value] / [onChanged]);无有效年时回调 null。
|
|
||||||
class DatePickerField extends StatefulWidget {
|
class DatePickerField extends StatefulWidget {
|
||||||
final String? value; // yyyy-MM-dd
|
final String? value; // yyyy-MM-dd
|
||||||
final ValueChanged<String?> onChanged;
|
final ValueChanged<String?> onChanged;
|
||||||
@@ -28,55 +28,12 @@ class DatePickerField extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DatePickerFieldState extends State<DatePickerField> {
|
class _DatePickerFieldState extends State<DatePickerField> {
|
||||||
late final TextEditingController _ctrl;
|
Future<void> _pick(
|
||||||
final FocusNode _focus = FocusNode();
|
BuildContext anchorCtx, FormFieldState<String> field) async {
|
||||||
|
final d =
|
||||||
@override
|
await showDsDateDropdown(anchorCtx, initial: parseYmd(widget.value));
|
||||||
void initState() {
|
if (d != null) {
|
||||||
super.initState();
|
final v = formatYmd(d);
|
||||||
_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;
|
|
||||||
field.didChange(v);
|
field.didChange(v);
|
||||||
widget.onChanged(v);
|
widget.onChanged(v);
|
||||||
}
|
}
|
||||||
@@ -84,38 +41,57 @@ class _DatePickerFieldState extends State<DatePickerField> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final t = context.tokens;
|
||||||
return FormField<String>(
|
return FormField<String>(
|
||||||
initialValue: widget.value,
|
initialValue: widget.value,
|
||||||
validator: widget.isRequired
|
validator: widget.isRequired
|
||||||
? (_) => normalizeYmd(_ctrl.text) == null ? '请输入日期' : null
|
? (_) =>
|
||||||
|
(widget.value == null || widget.value!.isEmpty) ? '请输入日期' : null
|
||||||
: null,
|
: null,
|
||||||
builder: (field) {
|
builder: (field) {
|
||||||
return TextField(
|
final has = widget.value != null && widget.value!.isNotEmpty;
|
||||||
controller: _ctrl,
|
return Column(
|
||||||
focusNode: _focus,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
keyboardType: TextInputType.datetime,
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: const TextStyle(fontSize: 13),
|
children: [
|
||||||
decoration: InputDecoration(
|
Builder(
|
||||||
isDense: true,
|
builder: (anchorCtx) => InkWell(
|
||||||
hintText: '年-月-日',
|
onTap: () => _pick(anchorCtx, field),
|
||||||
hintStyle: const TextStyle(fontSize: 12),
|
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||||
contentPadding:
|
child: Container(
|
||||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
height: 38,
|
||||||
errorText: field.errorText,
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
suffixIcon: IconButton(
|
decoration: BoxDecoration(
|
||||||
icon: Icon(LucideIcons.calendar,
|
color: t.surface,
|
||||||
size: 16, color: context.tokens.muted),
|
border:
|
||||||
padding: EdgeInsets.zero,
|
Border.all(color: field.hasError ? t.danger : t.border),
|
||||||
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
|
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||||
tooltip: '选择日期',
|
),
|
||||||
onPressed: () => _pickFromCalendar(field),
|
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),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (field.errorText != null)
|
||||||
onChanged: (v) {
|
Padding(
|
||||||
final n = normalizeYmd(v);
|
padding: const EdgeInsets.only(top: 4, left: 2),
|
||||||
field.didChange(n);
|
child: Text(field.errorText!,
|
||||||
widget.onChanged(n);
|
style: TextStyle(fontSize: AppDims.fsXs, color: t.danger)),
|
||||||
},
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,19 +33,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('DatePickerField', () {
|
group('DatePickerField', () {
|
||||||
testWidgets('渲染可键入框 + 日历图标', (tester) async {
|
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 {
|
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(MaterialApp(
|
||||||
theme: buildTheme('a'),
|
theme: buildTheme('a'),
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
@@ -54,30 +42,56 @@ void main() {
|
|||||||
));
|
));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('2026-06-19'), findsOneWidget);
|
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;
|
String? out;
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(MaterialApp(
|
||||||
theme: buildTheme('a'),
|
theme: buildTheme('a'),
|
||||||
home: Scaffold(
|
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');
|
await tester.pumpAndSettle();
|
||||||
expect(out, '2024-01-01');
|
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;
|
String? out;
|
||||||
await tester.pumpWidget(MaterialApp(
|
await tester.pumpWidget(MaterialApp(
|
||||||
theme: buildTheme('a'),
|
theme: buildTheme('a'),
|
||||||
home: Scaffold(
|
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');
|
await tester.pumpAndSettle();
|
||||||
expect(out, '2024-05-01');
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |