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」
|
||||
/// 「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.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<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(
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
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 |