Files
jiu/client/lib/widgets/wheel_date_picker.dart
T
wangjia 06dc68245a feat(client): 日期范围改锚定下拉——起止并排双滚轮 + 可键入,不再冻结窗口
- 新增 showDateRangeDropdown:锚定触发控件下方(透明遮罩点外关闭,形态同
  DsMenu 下拉),起始/结束两列并排,各带可输入框(键入/回车/失焦归一
  yyyy-MM-dd)与滚轮,输入↔滚轮双向联动;共用底栏显示当前范围 + 确定,
  起止倒置自动交换;窄屏回落既有两步滚轮弹窗
- WheelDatePanel 加 showFooter/onChanged(滚动即回调),供范围面板复用
- 出入库列表 6 处调用切换:工具栏时间 chip「自定义…」、桌面详搜弹窗、
  窄屏详搜 sheet 的日期区间字段均锚定在各自控件下方

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:29:21 +08:00

622 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../core/responsive/responsive.dart';
import '../core/theme/context_tokens.dart';
import '../core/theme/app_tokens.dart';
import '../core/theme/app_dims.g.dart';
import '../core/utils/date_util.dart';
import '../core/utils/dialog_util.dart';
import '../core/theme/app_fonts.dart';
/// 年/月/日 滚轮日期选择面板(对齐原型单一真源 .wheel-pop / datewheel.js)。
/// 三列纯数字(年 4 位、月/日补零两位,等宽字体),中间高亮带 = .wheel-center
/// 居中项为 primary 加粗 16px;底部「今天」+「确定」,无取消(点外部取消)。
///
/// 本体是自包含的 248px 卡片(Material 描边+阴影),既可放进锚定浮层(单据/生产日期,
/// 挂在字段下方,对齐原型 datewheel.js 的 pop 定位),也可放进居中弹窗(范围选择)。
/// 「确定」时回调 [onCommit];「今天」仅在面板内滚动到今天,不提交。
class WheelDatePanel extends StatefulWidget {
final DateTime initial;
final ValueChanged<DateTime> onCommit;
/// 可选标题(范围选择用「起始/结束日期」区分);空则不渲染(对齐原型无标题)。
final String title;
/// 滚动即回调当前值(范围下拉的联动输入框用);null 不回调。
final ValueChanged<DateTime>? onChanged;
/// false 隐藏「今天/确定」底栏(范围下拉共用一个底栏时用),onCommit 不再触发。
final bool showFooter;
const WheelDatePanel({
super.key,
required this.initial,
required this.onCommit,
this.title = '',
this.onChanged,
this.showFooter = true,
});
@override
State<WheelDatePanel> createState() => _WheelDatePanelState();
}
const double _kItem = 36; // .wheel-item 行高
const double _kColsH = 180; // .wheel-cols 高度(5 行)
const double _kWidth = 248; // .wheel-pop 宽度
String _pad2(int n) => n.toString().padLeft(2, '0');
int _daysIn(int y, int m) => DateTime(y, m + 1, 0).day; // m: 1-12
class _WheelDatePanelState extends State<WheelDatePanel> {
late final List<int> _years; // now-15 .. now+2(对齐 datewheel.js
late int _yIdx, _mIdx, _dIdx;
late final FixedExtentScrollController _yCtrl, _mCtrl, _dCtrl;
int get _year => _years[_yIdx];
int get _month => _mIdx + 1;
@override
void initState() {
super.initState();
final now = DateTime.now();
_years = [for (var y = now.year - 15; y <= now.year + 2; y++) y];
final v = widget.initial;
_yIdx = _years.indexOf(v.year);
if (_yIdx < 0) _yIdx = _years.indexOf(now.year);
if (_yIdx < 0) _yIdx = 0;
_mIdx = (v.month - 1).clamp(0, 11);
_dIdx = (v.day - 1).clamp(0, _daysIn(v.year, v.month) - 1);
_yCtrl = FixedExtentScrollController(initialItem: _yIdx);
_mCtrl = FixedExtentScrollController(initialItem: _mIdx);
_dCtrl = FixedExtentScrollController(initialItem: _dIdx);
}
@override
void dispose() {
_yCtrl.dispose();
_mCtrl.dispose();
_dCtrl.dispose();
super.dispose();
}
// 年/月变化后重算当月天数,超界则回夹并吸附。
void _onYearOrMonth() {
final n = _daysIn(_year, _month);
if (_dIdx > n - 1) {
_dIdx = n - 1;
_dCtrl.jumpToItem(_dIdx);
}
setState(() {});
widget.onChanged?.call(DateTime(_year, _month, _dIdx + 1));
}
void _goToday() {
final now = DateTime.now();
final yi = _years.indexOf(now.year);
if (yi >= 0) _yIdx = yi;
_mIdx = now.month - 1;
_dIdx = now.day - 1;
const d = Duration(milliseconds: 250);
_yCtrl.animateToItem(_yIdx, duration: d, curve: Curves.easeOut);
_mCtrl.animateToItem(_mIdx, duration: d, curve: Curves.easeOut);
_dCtrl.animateToItem(_dIdx, duration: d, curve: Curves.easeOut);
setState(() {});
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final days = _daysIn(_year, _month);
return Material(
color: t.surface,
elevation: 6,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
width: _kWidth,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.title.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(4, 2, 4, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(widget.title,
style: TextStyle(
fontSize: AppDims.fsTitle,
fontWeight: FontWeight.w600,
color: t.heading)),
),
),
// 三列滚轮 + 中间高亮带
SizedBox(
height: _kColsH,
child: Stack(
children: [
Positioned(
left: 4,
right: 4,
top: (_kColsH - _kItem) / 2,
height: _kItem,
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
color: t.bg,
borderRadius: BorderRadius.circular(AppDims.rMd),
border: Border(
top: BorderSide(color: t.border),
bottom: BorderSide(color: t.border),
),
),
),
),
),
Row(
children: [
_col(t, _yCtrl, _years.length,
(i) => _years[i].toString(), _yIdx, (i) {
_yIdx = i;
_onYearOrMonth();
}),
_col(t, _mCtrl, 12, (i) => _pad2(i + 1), _mIdx, (i) {
_mIdx = i;
_onYearOrMonth();
}),
_col(t, _dCtrl, days, (i) => _pad2(i + 1), _dIdx, (i) {
setState(() => _dIdx = i);
widget.onChanged
?.call(DateTime(_year, _month, _dIdx + 1));
}),
],
),
],
),
),
// 底栏:今天(左) / 确定(右,无取消,点外部取消)
if (widget.showFooter)
Container(
margin: const EdgeInsets.only(top: 6),
padding: const EdgeInsets.fromLTRB(4, 8, 4, 2),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: t.borderSubtle)),
),
child: Row(
children: [
InkWell(
onTap: _goToday,
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
child: Text('今天',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted)),
),
),
const Spacer(),
InkWell(
onTap: () =>
widget.onCommit(DateTime(_year, _month, _dIdx + 1)),
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 5),
decoration: BoxDecoration(
color: t.primary,
borderRadius: BorderRadius.circular(AppDims.rSm),
),
child: Text('确定',
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.onPrimary,
fontWeight: FontWeight.w600)),
),
),
],
),
),
],
),
),
);
}
Widget _col(
AppTokens t,
FixedExtentScrollController ctrl,
int count,
String Function(int) label,
int selIdx,
ValueChanged<int> onSel,
) {
return Expanded(
child: ListWheelScrollView.useDelegate(
controller: ctrl,
itemExtent: _kItem,
physics: const FixedExtentScrollPhysics(),
diameterRatio: 100, // 近似平面,去掉滚轮弧度
perspective: 0.0001,
onSelectedItemChanged: onSel,
childDelegate: ListWheelChildBuilderDelegate(
childCount: count,
builder: (c, i) {
final on = i == selIdx;
return Center(
child: Text(
label(i),
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: on ? 16 : AppDims.fsBody,
fontWeight: on ? FontWeight.w700 : FontWeight.w400,
color: on ? t.primary : t.muted,
),
),
);
},
),
),
);
}
}
/// 居中弹窗形式(范围选择用)。锚定字段的用法见 DsDateCell(直接嵌 WheelDatePanel 到浮层)。
Future<DateTime?> showWheelDatePicker(
BuildContext context, {
DateTime? initial,
String title = '',
}) {
return showAppDialog<DateTime>(
context: context,
builder: (dctx) => Align(
alignment: Alignment.center,
child: WheelDatePanel(
initial: initial ?? DateTime.now(),
title: title,
onCommit: (d) => Navigator.of(dctx).pop(d),
),
),
);
}
/// 范围选择:先选起始日、再选结束日;任一取消则返回 null。
Future<DateTimeRange?> showWheelDateRange(
BuildContext context, {
DateTimeRange? initial,
}) async {
final start = await showWheelDatePicker(context,
initial: initial?.start, title: '起始日期');
if (start == null || !context.mounted) return null;
final end = await showWheelDatePicker(context,
initial: initial?.end ?? start, title: '结束日期');
if (end == null) return null;
return end.isBefore(start)
? DateTimeRange(start: end, end: start)
: DateTimeRange(start: start, end: end);
}
// ═══════════════ 日期范围下拉(桌面)═══════════════
// 锚定触发控件下方的双列面板:起始 | 结束 并排各一组「可输入框 + 滚轮」,
// 透明遮罩点外关闭(不冻结窗口,形态同 DsMenu 下拉);共用一个「确定」底栏。
// 窄屏(无悬停、面板超宽)回落到居中滚轮弹窗(showWheelDateRange)。
/// 打开日期范围下拉,锚定在 [anchorContext](触发控件自身的 context)下方。
/// 确定返回 DateTimeRange(起止倒置自动交换);点外部关闭返回 null(不变更)。
Future<DateTimeRange?> showDateRangeDropdown(
BuildContext anchorContext, {
DateTimeRange? initial,
}) {
if (anchorContext.isMobile) {
// 窄屏:双列 530px 放不下,保持既有两步滚轮弹窗
return showWheelDateRange(anchorContext, initial: initial);
}
final overlay = Navigator.of(anchorContext)
.overlay!
.context
.findRenderObject() as RenderBox;
final box = anchorContext.findRenderObject() as RenderBox?;
final rect = box == null
? Rect.zero
: box.localToGlobal(Offset.zero, ancestor: overlay) & box.size;
return Navigator.of(anchorContext).push<DateTimeRange>(
_RangeDropdownRoute(anchorRect: rect, initial: initial),
);
}
class _RangeDropdownRoute extends PopupRoute<DateTimeRange> {
final Rect anchorRect;
final DateTimeRange? initial;
_RangeDropdownRoute({required this.anchorRect, this.initial});
@override
Color? get barrierColor => Colors.transparent;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => 'date-range';
@override
Duration get transitionDuration => Duration.zero;
@override
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
return CustomSingleChildLayout(
delegate: _RangeDropdownLayout(anchorRect),
child: _DateRangePanel(
initial: initial,
onCommit: (r) => Navigator.of(context).pop(r),
),
);
}
}
class _RangeDropdownLayout extends SingleChildLayoutDelegate {
final Rect anchor;
_RangeDropdownLayout(this.anchor);
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) =>
BoxConstraints(
maxWidth: math.max(0, constraints.maxWidth - 16),
maxHeight: math.max(0, constraints.maxHeight - 16),
);
@override
Offset getPositionForChild(Size size, Size childSize) {
final left = anchor.left
.clamp(8.0, math.max(8.0, size.width - childSize.width - 8))
.toDouble();
final below = anchor.bottom + 6;
final above = anchor.top - childSize.height - 6;
final top = (below + childSize.height <= size.height - 8 || above < 8)
? below
: above;
return Offset(left, top.clamp(8.0, math.max(8.0, size.height - 8)));
}
@override
bool shouldRelayout(_RangeDropdownLayout old) => old.anchor != anchor;
}
class _DateRangePanel extends StatefulWidget {
final DateTimeRange? initial;
final ValueChanged<DateTimeRange> onCommit;
const _DateRangePanel({this.initial, required this.onCommit});
@override
State<_DateRangePanel> createState() => _DateRangePanelState();
}
class _DateRangePanelState extends State<_DateRangePanel> {
late DateTime _start = widget.initial?.start ?? DateTime.now();
late DateTime _end = widget.initial?.end ?? DateTime.now();
void _commit() {
final r = _end.isBefore(_start)
? DateTimeRange(start: _end, end: _start)
: DateTimeRange(start: _start, end: _end);
widget.onCommit(r);
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Material(
color: t.surface,
elevation: 10,
borderRadius: BorderRadius.circular(AppDims.rLg),
child: Container(
width: 552,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _RangeSide(
title: '起始日期',
value: _start,
onChanged: (d) => setState(() => _start = d),
),
),
const SizedBox(width: 12),
Expanded(
child: _RangeSide(
title: '结束日期',
value: _end,
onChanged: (d) => setState(() => _end = d),
),
),
],
),
// 底栏(共用):范围文案(左)/ 确定(右);点外部即取消
Container(
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: t.borderSubtle)),
),
child: Row(
children: [
Expanded(
child: Text(
'${formatYmd(_start)} ~ ${formatYmd(_end)}',
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.muted,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback),
),
),
InkWell(
onTap: _commit,
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 5),
decoration: BoxDecoration(
color: t.primary,
borderRadius: BorderRadius.circular(AppDims.rSm),
),
child: Text('确定',
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.onPrimary,
fontWeight: FontWeight.w600)),
),
),
],
),
),
],
),
),
);
}
}
/// 范围面板单侧:标题 + 可输入框(键入即联动滚轮)+ 滚轮(滚动即联动输入框)。
class _RangeSide extends StatefulWidget {
final String title;
final DateTime value;
final ValueChanged<DateTime> onChanged;
const _RangeSide(
{required this.title, required this.value, required this.onChanged});
@override
State<_RangeSide> createState() => _RangeSideState();
}
class _RangeSideState extends State<_RangeSide> {
late final TextEditingController _ctrl =
TextEditingController(text: formatYmd(widget.value));
final FocusNode _focus = FocusNode();
// 键入归一后的滚轮锚定值:仅键入产生完整有效日期时更新(驱动滚轮重建吸附)
late DateTime _wheelAnchor = widget.value;
bool _syncing = false; // 滚轮回写输入框时跳过 onChanged 循环
@override
void initState() {
super.initState();
_focus.addListener(() {
if (!_focus.hasFocus) {
// 失焦归一显示(2024 → 2024-01-01)并吸附滚轮
final n = normalizeYmd(_ctrl.text);
if (n != null) {
_syncing = true;
_ctrl.text = n;
_syncing = false;
_applyTyped(n);
} else {
_syncing = true;
_ctrl.text = formatYmd(widget.value);
_syncing = false;
}
}
});
}
@override
void dispose() {
_ctrl.dispose();
_focus.dispose();
super.dispose();
}
void _applyTyped(String ymd) {
final d = parseYmd(ymd);
if (d == null) return;
widget.onChanged(d);
if (d != _wheelAnchor) setState(() => _wheelAnchor = d);
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 6),
child: Text(widget.title,
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.muted)),
),
Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 10),
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: _focus.hasFocus ? t.primary : t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
alignment: Alignment.centerLeft,
child: TextField(
controller: _ctrl,
focusNode: _focus,
keyboardType: TextInputType.datetime,
style: TextStyle(
fontSize: AppDims.fsBody,
color: t.text,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback),
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
hintText: '年-月-日',
hintStyle: TextStyle(fontSize: AppDims.fsBody, color: t.faint),
),
onChanged: (v) {
if (_syncing) return;
// 键入完整日期(含 8 位纯数字)即联动滚轮;不完整先不动
final n = normalizeYmd(v);
if (n != null && v.trim().length >= 8) _applyTyped(n);
},
onSubmitted: (v) {
final n = normalizeYmd(v);
if (n != null) {
_syncing = true;
_ctrl.text = n;
_syncing = false;
_applyTyped(n);
}
},
),
),
WheelDatePanel(
key: ValueKey('${widget.title}-${formatYmd(_wheelAnchor)}'),
initial: _wheelAnchor,
showFooter: false,
onCommit: (_) {},
onChanged: (d) {
widget.onChanged(d);
_syncing = true;
_ctrl.text = formatYmd(d);
_syncing = false;
},
),
],
);
}
}