Files
jiu/client/lib/widgets/wheel_date_picker.dart
T
wangjia cd477ce81a fix(client): 抽屉内操作不再收抽屉(原地刷新)+ 日期输入框垂直居中
- 出入库详情抽屉:通过/拒绝/撤回/提交/结清/退单/确认定价/打印 全部不收抽屉,
  操作完成后原地重拉单据刷新内容(StatefulBuilder+refresh);仅 遮罩/X/
  修改·删除(离开语义)关闭——对齐拍板「点抽屉外才收回」
- 往来抽屉「编辑」、财务往来抽屉「登记收款/付款」(完成后流水原地刷新)、
  商品编辑抽屉「保存」同规则;「取消」保持关闭语义
- 日期组件输入框 forceStrutHeight 锁行盒:修等宽字体 ascent/descent 不对称
  导致的文字垂直偏移

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

629 lines
20 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.
// widgets/wheel_date_picker.dart — 统一日期选择组件(扁平风格,2026-07-04 拍板)。
//
// 形态(桌面=锚定下拉、窄屏=底部 sheet,内容同一套窗格):
// - 单日期 showDsDateDropdown:面板 = 可输入框 + 三列滚轮 + 底栏(今天/确定)
// - 范围 showDateRangeDropdown:起始|结束 两窗格并排(窄屏纵排)+ 共用底栏(预览/确定)
// 窗格(_DatePane):输入框与滚轮同宽(248)、文字水平垂直居中、mono;键入/回车/失焦
// 归一 yyyy-MM-dd 并联动滚轮,滚轮滚动实时回写输入框。全程无阴影/立体效果。
//
// 所有日期选择统一走本组件:录单日期格 DsDateCell、登记收支 DatePickerField、
// 列表时间范围(工具栏/详搜)。滚轮本体对齐原型 datewheel.js 三列结构。
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/theme/app_fonts.dart';
import 'ds/m_sheet.dart';
const double _kItem = 36; // .wheel-item 行高
const double _kColsH = 180; // .wheel-cols 高度(5 行)
const double _kWidth = 248; // 窗格宽度(输入框与滚轮同宽)
String _pad2(int n) => n.toString().padLeft(2, '0');
int _daysIn(int y, int m) => DateTime(y, m + 1, 0).day; // m: 1-12
// ═══════════════ 对外入口 ═══════════════
/// 单日期选择:桌面锚定 [anchorContext] 下方的下拉(透明遮罩点外关闭,不冻结窗口);
/// 窄屏底部 sheet。确定返回日期,点外部/取消返回 null。
Future<DateTime?> showDsDateDropdown(
BuildContext anchorContext, {
DateTime? initial,
String title = '选择日期',
}) {
if (anchorContext.isMobile) {
return showMSheet<DateTime>(
anchorContext,
title: title,
builder: (ctx) => Center(
child: _SingleDateBody(
initial: initial ?? DateTime.now(),
onCommit: (d) => Navigator.of(ctx).pop(d),
),
),
);
}
return Navigator.of(anchorContext).push<DateTime>(_DropdownRoute<DateTime>(
anchorRect: _anchorRect(anchorContext),
builder: (ctx) => _DropCard(
child: _SingleDateBody(
initial: initial ?? DateTime.now(),
onCommit: (d) => Navigator.of(ctx).pop(d),
),
),
));
}
/// 日期范围选择:桌面锚定下拉(起始|结束并排);窄屏底部 sheet(纵排)。
/// 确定返回 DateTimeRange(起止倒置自动交换),点外部/取消返回 null。
Future<DateTimeRange?> showDateRangeDropdown(
BuildContext anchorContext, {
DateTimeRange? initial,
}) {
if (anchorContext.isMobile) {
return showMSheet<DateTimeRange>(
anchorContext,
title: '日期区间',
builder: (ctx) => Center(
child: _RangeBody(
initial: initial,
vertical: true,
onCommit: (r) => Navigator.of(ctx).pop(r),
),
),
);
}
return Navigator.of(anchorContext)
.push<DateTimeRange>(_DropdownRoute<DateTimeRange>(
anchorRect: _anchorRect(anchorContext),
builder: (ctx) => _DropCard(
child: _RangeBody(
initial: initial,
vertical: false,
onCommit: (r) => Navigator.of(ctx).pop(r),
),
),
));
}
Rect _anchorRect(BuildContext anchorContext) {
final overlay = Navigator.of(anchorContext)
.overlay!
.context
.findRenderObject() as RenderBox;
final box = anchorContext.findRenderObject() as RenderBox?;
return box == null
? Rect.zero
: box.localToGlobal(Offset.zero, ancestor: overlay) & box.size;
}
// ═══════════════ 下拉路由(锚定,透明遮罩)═══════════════
class _DropdownRoute<T> extends PopupRoute<T> {
final Rect anchorRect;
final WidgetBuilder builder;
_DropdownRoute({required this.anchorRect, required this.builder});
@override
Color? get barrierColor => Colors.transparent;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => 'date-dropdown';
@override
Duration get transitionDuration => Duration.zero;
@override
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
return CustomSingleChildLayout(
delegate: _DropdownLayout(anchorRect),
child: builder(context),
);
}
}
class _DropdownLayout extends SingleChildLayoutDelegate {
final Rect anchor;
_DropdownLayout(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(_DropdownLayout old) => old.anchor != anchor;
}
/// 下拉卡壳(扁平):surface + 1px 边框 + r-lg,无阴影。
class _DropCard extends StatelessWidget {
final Widget child;
const _DropCard({required this.child});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Material(
color: t.surface,
shape: RoundedRectangleBorder(
side: BorderSide(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Padding(padding: const EdgeInsets.all(12), child: child),
);
}
}
// ═══════════════ 单日期面板体 ═══════════════
class _SingleDateBody extends StatefulWidget {
final DateTime initial;
final ValueChanged<DateTime> onCommit;
const _SingleDateBody({required this.initial, required this.onCommit});
@override
State<_SingleDateBody> createState() => _SingleDateBodyState();
}
class _SingleDateBodyState extends State<_SingleDateBody> {
late DateTime _value = widget.initial;
int _paneEpoch = 0; // 「今天」时驱动窗格重建吸附
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_DatePane(
key: ValueKey('single-$_paneEpoch'),
value: _value,
onChanged: (d) => _value = d,
),
// 底栏(扁平):今天(左)/ 确定(右)
Container(
width: _kWidth,
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: t.borderSubtle)),
),
child: Row(
children: [
InkWell(
onTap: () => setState(() {
_value = DateTime.now();
_paneEpoch++;
}),
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(),
_CommitButton(onTap: () => widget.onCommit(_value)),
],
),
),
],
);
}
}
// ═══════════════ 范围面板体 ═══════════════
class _RangeBody extends StatefulWidget {
final DateTimeRange? initial;
final bool vertical; // 窄屏纵排
final ValueChanged<DateTimeRange> onCommit;
const _RangeBody(
{this.initial, required this.vertical, required this.onCommit});
@override
State<_RangeBody> createState() => _RangeBodyState();
}
class _RangeBodyState extends State<_RangeBody> {
late DateTime _start = widget.initial?.start ?? DateTime.now();
late DateTime _end = widget.initial?.end ?? DateTime.now();
void _commit() {
widget.onCommit(_end.isBefore(_start)
? DateTimeRange(start: _end, end: _start)
: DateTimeRange(start: _start, end: _end));
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final startPane = _DatePane(
title: '起始日期',
value: _start,
onChanged: (d) => setState(() => _start = d),
);
final endPane = _DatePane(
title: '结束日期',
value: _end,
onChanged: (d) => setState(() => _end = d),
);
final footerWidth = widget.vertical ? _kWidth : _kWidth * 2 + 12; // 与窗格区同宽
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (widget.vertical) ...[
startPane,
const SizedBox(height: 14),
endPane,
] else
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [startPane, const SizedBox(width: 12), endPane],
),
// 共用底栏(扁平):范围预览(左)/ 确定(右)
Container(
width: footerWidth,
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),
),
),
_CommitButton(onTap: _commit),
],
),
),
],
);
}
}
class _CommitButton extends StatelessWidget {
final VoidCallback onTap;
const _CommitButton({required this.onTap});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return InkWell(
onTap: onTap,
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)),
),
);
}
}
// ═══════════════ 窗格:可输入框 + 三列滚轮(同宽 248,扁平)═══════════════
class _DatePane extends StatefulWidget {
final String? title;
final DateTime value;
final ValueChanged<DateTime> onChanged;
const _DatePane(
{super.key, this.title, required this.value, required this.onChanged});
@override
State<_DatePane> createState() => _DatePaneState();
}
class _DatePaneState extends State<_DatePane> {
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);
_setText(n ?? formatYmd(_wheelAnchor));
if (n != null) _applyTyped(n);
}
if (mounted) setState(() {});
});
}
@override
void dispose() {
_ctrl.dispose();
_focus.dispose();
super.dispose();
}
void _setText(String v) {
_syncing = true;
_ctrl.text = v;
_syncing = false;
}
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 SizedBox(
width: _kWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.title != null)
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 6),
child: Text(widget.title!,
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.muted)),
),
// 输入框:与滚轮同宽、文字水平垂直居中、mono、扁平
Container(
height: 34,
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),
),
child: Center(
child: TextField(
controller: _ctrl,
focusNode: _focus,
keyboardType: TextInputType.datetime,
textAlign: TextAlign.center,
// 强制 strut 统一行盒(等宽字体 ascent/descent 不对称会让文字
// 视觉偏离垂直中心;height:1 行高=字号,Center 精确居中)
strutStyle: const StrutStyle(
fontSize: AppDims.fsBody,
height: 1.0,
forceStrutHeight: true,
),
style: TextStyle(
fontSize: AppDims.fsBody,
height: 1.0,
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) {
_setText(n);
_applyTyped(n);
}
},
),
),
),
_WheelCols(
key: ValueKey(formatYmd(_wheelAnchor)),
initial: _wheelAnchor,
onChanged: (d) {
widget.onChanged(d);
_setText(formatYmd(d));
},
),
],
),
);
}
}
// ═══════════════ 三列滚轮(扁平;对齐原型 datewheel.js 三列结构)═══════════════
class _WheelCols extends StatefulWidget {
final DateTime initial;
final ValueChanged<DateTime> onChanged;
const _WheelCols({super.key, required this.initial, required this.onChanged});
@override
State<_WheelCols> createState() => _WheelColsState();
}
class _WheelColsState extends State<_WheelCols> {
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 _emit() => widget.onChanged(DateTime(_year, _month, _dIdx + 1));
// 年/月变化后重算当月天数,超界则回夹并吸附。
void _onYearOrMonth() {
final n = _daysIn(_year, _month);
if (_dIdx > n - 1) {
_dIdx = n - 1;
_dCtrl.jumpToItem(_dIdx);
}
setState(() {});
_emit();
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final days = _daysIn(_year, _month);
// 扁平:仅 1px 边框 + 圆角,无阴影/立体
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: 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),
),
),
),
),
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);
_emit();
}),
],
),
],
),
),
);
}
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,
),
),
);
},
),
),
);
}
}