Files
jiu/client/lib/widgets/wheel_date_picker.dart
T
wangjia 6238b86dcb feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 09:58:14 +08:00

288 lines
9.9 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 'package:flutter/material.dart';
import '../core/theme/context_tokens.dart';
import '../core/theme/app_tokens.dart';
import '../core/theme/app_dims.g.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;
const WheelDatePanel({
super.key,
required this.initial,
required this.onCommit,
this.title = '',
});
@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(() {});
}
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)),
],
),
],
),
),
// 底栏:今天(左) / 确定(右,无取消,点外部取消)
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);
}