a76724b385
新增 DatePickerField 日期选择器与 date_util;可搜索下拉 SearchableOptionField 支持内联新建选项;入库/出库录单表单、商品页、财务页、app_shell 导航相应调整。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
180 lines
5.4 KiB
Dart
180 lines
5.4 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../core/theme/app_theme.dart';
|
|
import '../core/utils/date_util.dart';
|
|
|
|
/// 年/月/日 三个可编辑下拉框的日期选择组件,替代原生 showDatePicker。
|
|
///
|
|
/// - 每个下拉是 Material 3 `DropdownMenu`:点开可选,键入数字即过滤定位
|
|
/// (年/月/日均为有界域,条目覆盖全范围,等价于「手动输入数字」)。
|
|
/// - 改月/年时把超出当月的「日」自动 clamp 回当月最大值。
|
|
/// - 值以 `yyyy-MM-dd` 字符串进出([value] / [onChanged]);三者未填齐时回调 null。
|
|
class DatePickerField extends StatefulWidget {
|
|
final String? value; // yyyy-MM-dd
|
|
final ValueChanged<String?> onChanged;
|
|
final bool isRequired;
|
|
final String? label;
|
|
|
|
const DatePickerField({
|
|
super.key,
|
|
this.value,
|
|
required this.onChanged,
|
|
this.isRequired = false,
|
|
this.label,
|
|
});
|
|
|
|
@override
|
|
State<DatePickerField> createState() => _DatePickerFieldState();
|
|
}
|
|
|
|
class _DatePickerFieldState extends State<DatePickerField> {
|
|
int? _year;
|
|
int? _month;
|
|
int? _day;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_parse(widget.value);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(DatePickerField old) {
|
|
super.didUpdateWidget(old);
|
|
if (old.value != widget.value) {
|
|
_parse(widget.value);
|
|
}
|
|
}
|
|
|
|
void _parse(String? v) {
|
|
final d = parseYmd(v);
|
|
_year = d?.year;
|
|
_month = d?.month;
|
|
_day = d?.day;
|
|
}
|
|
|
|
int get _daysInMonth {
|
|
final y = _year ?? DateTime.now().year;
|
|
final m = _month ?? 1;
|
|
return DateTime(y, m + 1, 0).day; // 下月第 0 天 = 当月最后一天
|
|
}
|
|
|
|
String? get _composed => composeYmd(_year, _month, _day);
|
|
|
|
void _emit(FormFieldState<String> field) {
|
|
// 改月/年后把超界的日 clamp 回当月最大值(与 composeYmd 一致,State 同步显示)
|
|
if (_day != null && _day! > _daysInMonth) {
|
|
_day = _daysInMonth;
|
|
}
|
|
final v = _composed;
|
|
field.didChange(v);
|
|
widget.onChanged(v);
|
|
setState(() {});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final now = DateTime.now();
|
|
final years = [for (var y = now.year - 20; y <= now.year + 5; y++) y];
|
|
final months = [for (var m = 1; m <= 12; m++) m];
|
|
final days = [for (var d = 1; d <= _daysInMonth; d++) d];
|
|
|
|
// 每个下拉用 Expanded 撑满分得的宽度(expandedInsets:zero 让 DropdownMenu 填满父级),
|
|
// trailingIcon 用紧凑小箭头,避免默认大图标按钮挤掉数字(曾导致「2026」被裁成「007」)。
|
|
Widget menu({
|
|
required String label,
|
|
required int? value,
|
|
required List<int> items,
|
|
required ValueChanged<int?> onSel,
|
|
required FormFieldState<String> field,
|
|
}) {
|
|
return DropdownMenu<int>(
|
|
initialSelection: value,
|
|
label: Text(label, style: const TextStyle(fontSize: 11)),
|
|
enableFilter: true,
|
|
requestFocusOnTap: true,
|
|
textStyle: const TextStyle(fontSize: 13),
|
|
menuHeight: 280,
|
|
expandedInsets: EdgeInsets.zero,
|
|
trailingIcon: const Icon(Icons.arrow_drop_down, size: 18),
|
|
selectedTrailingIcon: const Icon(Icons.arrow_drop_up, size: 18),
|
|
inputDecorationTheme: const InputDecorationTheme(
|
|
isDense: true,
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 6, vertical: 8),
|
|
),
|
|
dropdownMenuEntries: [
|
|
for (final i in items) DropdownMenuEntry<int>(value: i, label: '$i'),
|
|
],
|
|
onSelected: onSel,
|
|
);
|
|
}
|
|
|
|
return FormField<String>(
|
|
initialValue: widget.value,
|
|
validator: widget.isRequired
|
|
? (v) => (_composed == null ? '请选择日期' : null)
|
|
: null,
|
|
builder: (field) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 5,
|
|
child: menu(
|
|
label: '年',
|
|
value: _year,
|
|
items: years,
|
|
field: field,
|
|
onSel: (v) {
|
|
_year = v;
|
|
_emit(field);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
flex: 4,
|
|
child: menu(
|
|
label: '月',
|
|
value: _month,
|
|
items: months,
|
|
field: field,
|
|
onSel: (v) {
|
|
_month = v;
|
|
_emit(field);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
flex: 4,
|
|
child: menu(
|
|
label: '日',
|
|
value: _day,
|
|
items: days,
|
|
field: field,
|
|
onSel: (v) {
|
|
_day = v;
|
|
_emit(field);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (field.errorText != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 12, top: 4),
|
|
child: Text(
|
|
field.errorText!,
|
|
style: const TextStyle(fontSize: 11, color: AppTheme.danger),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|