Files
jiu/client/lib/screens/shared/order_form_shell.dart
wangjia fb236018f3 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>
2026-07-04 17:49:17 +08:00

924 lines
30 KiB
Dart
Raw Permalink 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:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/app_tokens.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/date_util.dart';
import '../../widgets/wheel_date_picker.dart';
import '../../core/theme/app_fonts.dart';
/// 录单页整体版式(对齐原型 stock-in.html`.page-head` + `.form-wrap` + `.form-foot`)。
///
/// 出库 / 入库两屏共用此壳:固定顶部页头、可滚动的表单主区(提示条 + 单据信息 +
/// 商品明细)、固定底部合计栏。窄屏自动收窄内边距、页头/底栏动作切到 [mobileActions]。
class OrderFormShell extends StatelessWidget {
final String title;
final Widget? statusBadge;
final VoidCallback onBack;
/// 桌面页头 / 底栏动作(同一组按钮两处呈现,对齐原型 desk 组)。
final List<Widget> deskActions;
/// 窄屏页头动作(主操作 + 溢出菜单)。
final List<Widget> mobileActions;
final Widget? notice;
final Widget docHead;
final Widget detailHead;
final Widget detail;
final String rowsLabel; // 明细 N 行
final String totalText; // 已格式化 ¥
final String? profitText; // 合计利润(出库·仅管理员;null 不渲染)
final bool loading;
const OrderFormShell({
super.key,
required this.title,
required this.onBack,
required this.deskActions,
required this.mobileActions,
required this.docHead,
required this.detailHead,
required this.detail,
required this.rowsLabel,
required this.totalText,
this.profitText,
this.statusBadge,
this.notice,
this.loading = false,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final mobile = context.isMobile;
return Scaffold(
backgroundColor: t.bg,
body: Column(
children: [
// ── 页头(.page-head)──
Padding(
padding: mobile
? const EdgeInsets.fromLTRB(8, 13, 16, 10)
: const EdgeInsets.fromLTRB(18, 16, 26, 14),
child: Row(
children: [
IconButton(
icon: Icon(LucideIcons.chevronLeft, size: 22, color: t.muted),
onPressed: onBack,
tooltip: '返回',
visualDensity: VisualDensity.compact,
),
const SizedBox(width: 6),
Text(title,
style: TextStyle(
fontSize: mobile ? AppDims.fsH2 : AppDims.fsH1,
fontWeight: FontWeight.w700,
color: t.title)),
if (statusBadge != null) ...[
const SizedBox(width: 12),
statusBadge!,
],
const Spacer(),
if (mobile)
...mobileActions
else
Wrap(spacing: 10, children: deskActions),
],
),
),
// ── 表单主区(.form-wrap,可滚动)──
Expanded(
child: loading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: mobile
? const EdgeInsets.fromLTRB(16, 0, 16, 14)
: const EdgeInsets.fromLTRB(26, 0, 26, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (notice != null) notice!,
docHead,
const SizedBox(height: 4),
detailHead,
detail,
],
),
),
),
// ── 底部合计栏(.form-foot)──
Container(
padding: mobile
? const EdgeInsets.symmetric(horizontal: 16, vertical: 12)
: const EdgeInsets.symmetric(horizontal: 26, vertical: 13),
decoration: BoxDecoration(
color: t.surface,
border: Border(top: BorderSide(color: t.border)),
),
child: Row(
children: [
Text(rowsLabel,
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const Spacer(),
if (profitText != null) ...[
Text('合计利润 ',
style:
TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
Text(profitText!,
style: TextStyle(
fontSize: AppDims.fsTitle,
color:
profitText!.contains('-') ? t.danger : t.success,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w700)),
const SizedBox(width: 18),
],
Text('合计金额 ',
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
Text(totalText,
style: TextStyle(
fontSize: 24,
color: t.accent,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w700,
letterSpacing: 0.3)),
if (!mobile) ...[
const SizedBox(width: 16),
Wrap(spacing: 10, children: deskActions),
],
],
),
),
],
),
);
}
}
/// 单据信息卡(.dochead):可折叠,标题行 + 收起态摘要 + 4 列字段网格 + 备注。
class DocHeadCard extends StatefulWidget {
final List<Widget> fields; // 4 个 DocField
final Widget remark;
final String summary; // 折叠态摘要(仓库 · 往来单位 · 日期)
const DocHeadCard({
super.key,
required this.fields,
required this.remark,
required this.summary,
});
@override
State<DocHeadCard> createState() => _DocHeadCardState();
}
class _DocHeadCardState extends State<DocHeadCard> {
bool _folded = false;
@override
Widget build(BuildContext context) {
final t = context.tokens;
final mobile = context.isMobile;
return Container(
margin: const EdgeInsets.only(top: 0, bottom: 14),
padding:
EdgeInsets.symmetric(horizontal: 18, vertical: _folded ? 13 : 16),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Text('单据信息',
style: TextStyle(
fontSize: AppDims.fsTitle,
fontWeight: FontWeight.w600,
color: t.heading)),
if (_folded) ...[
const SizedBox(width: 18),
Expanded(
child: Text(widget.summary,
overflow: TextOverflow.ellipsis,
style:
TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
),
] else
const Spacer(),
InkWell(
onTap: () => setState(() => _folded = !_folded),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(_folded ? '展开' : '收起',
style:
TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(width: 5),
Icon(
_folded
? LucideIcons.chevronDown
: LucideIcons.chevronUp,
size: 14,
color: t.muted),
]),
),
),
],
),
if (!_folded) ...[
const SizedBox(height: 14),
if (mobile)
Column(
children: [
for (final f in widget.fields)
Padding(
padding: const EdgeInsets.only(bottom: 14), child: f),
],
)
else
LayoutBuilder(builder: (context, c) {
const gap = 14.0;
final w = (c.maxWidth - gap * 3) / 4;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (final f in widget.fields) SizedBox(width: w, child: f),
],
);
}),
const SizedBox(height: 14),
widget.remark,
],
],
),
);
}
}
/// 单据信息里单个字段(label + 必填星 + 控件),对齐 `.field`。
class DocField extends StatelessWidget {
final String label;
final bool required;
final Widget child;
const DocField(
{super.key,
required this.label,
required this.child,
this.required = false});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(children: [
Text(label, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
if (required)
Text(' *',
style: TextStyle(fontSize: AppDims.fsSm, color: t.danger)),
]),
const SizedBox(height: 6),
child,
],
);
}
}
/// 只读值框(.ro-val):入库员/出库员等只读展示。
class RoValue extends StatelessWidget {
final String text;
const RoValue(this.text, {super.key});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Container(
height: 38,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.borderSubtle),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child:
Text(text, style: TextStyle(fontSize: AppDims.fsBody, color: t.text)),
);
}
}
/// 明细区标题行(.detail-head):标题 + 键盘提示 + 右侧动作。
class DetailHead extends StatelessWidget {
final List<Widget> actions;
const DetailHead({super.key, required this.actions});
static String modKey() =>
(defaultTargetPlatform == TargetPlatform.macOS && !kIsWeb) ? '' : 'Ctrl';
@override
Widget build(BuildContext context) {
final t = context.tokens;
final mobile = context.isMobile;
final mod = modKey();
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
children: [
Text('商品明细',
style: TextStyle(
fontSize: AppDims.fsTitle,
fontWeight: FontWeight.w600,
color: t.heading)),
if (!mobile) ...[
const SizedBox(width: 12),
Flexible(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
children: [
const _Kbd('Enter'),
_hintTxt(t, '下一格 · 末格自动加行 ·'),
_Kbd('$mod D'),
_hintTxt(t, '复制行 ·'),
_Kbd('$mod Enter'),
_hintTxt(t, '提交'),
],
),
),
],
const Spacer(),
...actions,
],
),
);
}
Widget _hintTxt(AppTokens t, String s) =>
Text(s, style: TextStyle(fontSize: AppDims.fsSm, color: t.faint));
}
class _Kbd extends StatelessWidget {
final String label;
const _Kbd(this.label);
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rSm),
),
child: Text(label,
style: TextStyle(
fontSize: AppDims.fsXs,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: t.muted)),
);
}
}
/// 明细网格列描述(表头 + 宽度 + 对齐 + 必填星)。
class GridCol {
final String key;
final String label;
final double? width; // null → 占满剩余(grow
final bool num;
final bool req;
const GridCol(this.key, this.label,
{this.width, this.num = false, this.req = false});
}
/// 明细网格表(.grid):粘性表头 + 行 + 底部「添加商品」。
/// 列内容由 [cellBuilder] 按 (rowIndex, colKey) 提供,两屏各自实现。
class OrderGridTable extends StatelessWidget {
final List<GridCol> columns;
final int rowCount;
final Widget Function(int rowIndex, String colKey) cellBuilder;
final bool Function(int rowIndex) rowHasError;
final VoidCallback? onAddRow;
const OrderGridTable({
super.key,
required this.columns,
required this.rowCount,
required this.cellBuilder,
required this.rowHasError,
this.onAddRow,
});
double get _minWidth {
double fixed = 0;
int grows = 0;
for (final c in columns) {
if (c.width != null) {
fixed += c.width!;
} else {
grows++;
}
}
return fixed + grows * 180;
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Container(
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
clipBehavior: Clip.antiAlias,
child: LayoutBuilder(builder: (context, c) {
final overflow = c.maxWidth < _minWidth;
final width = overflow ? _minWidth : c.maxWidth;
final table = SizedBox(
width: width,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_header(t),
for (var i = 0; i < rowCount; i++) _row(t, i),
],
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
overflow
? SingleChildScrollView(
scrollDirection: Axis.horizontal, child: table)
: table,
if (onAddRow != null) _addRow(t),
],
);
}),
);
}
Widget _cellWrap(GridCol col, Widget child) {
final aligned = Align(
alignment: col.num ? Alignment.centerRight : Alignment.centerLeft,
child: child,
);
if (col.width != null) return SizedBox(width: col.width, child: aligned);
return Expanded(child: aligned);
}
Widget _header(AppTokens t) {
return Container(
color: t.thBg,
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
for (final col in columns)
_cellWrap(
col,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Text.rich(
TextSpan(
children: [
TextSpan(text: col.label),
if (col.req)
TextSpan(text: ' *', style: TextStyle(color: t.danger)),
],
),
textAlign: col.num ? TextAlign.right : TextAlign.left,
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.muted,
fontWeight: FontWeight.w600),
),
),
),
],
),
);
}
Widget _row(AppTokens t, int i) {
final err = rowHasError(i);
return Container(
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.borderSubtle)),
boxShadow: err
? [
BoxShadow(
color: t.danger,
offset: const Offset(3, 0),
spreadRadius: -2)
]
: null,
),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
for (final col in columns)
_cellWrap(
col,
Padding(
padding: EdgeInsets.symmetric(
horizontal: col.key == 'act' ? 0 : 6, vertical: 4),
child: cellBuilder(i, col.key),
),
),
],
),
),
);
}
Widget _addRow(AppTokens t) {
return InkWell(
onTap: onAddRow,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: t.borderSubtle))),
child: Row(children: [
Icon(LucideIcons.plus, size: 16, color: t.primary),
const SizedBox(width: 8),
Text('添加商品',
style: TextStyle(
fontSize: AppDims.fsBody,
color: t.primary,
fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Text('末格 Enter 自动加行',
style: TextStyle(
fontSize: AppDims.fsXs,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: t.faint)),
]),
),
);
}
}
/// 网格内文本输入(.gci):透明、32 高、聚焦描边。数值列右对齐等宽。
class GciField extends StatelessWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final bool num;
final bool money;
final String hint;
final bool hasError;
final bool enabled;
final ValueChanged<String>? onChanged;
final VoidCallback? onEnter; // Enter 推进下一格
final bool Function({required bool backward})? onTab;
const GciField({
super.key,
required this.controller,
this.focusNode,
this.num = false,
this.money = false,
this.hint = '',
this.hasError = false,
this.enabled = true,
this.onChanged,
this.onEnter,
this.onTab,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Focus(
focusNode: focusNode,
onKeyEvent: (node, e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
if (e.logicalKey == LogicalKeyboardKey.tab) {
final back = HardwareKeyboard.instance.isShiftPressed;
final handled = onTab?.call(backward: back) ?? false;
return handled ? KeyEventResult.handled : KeyEventResult.ignored;
}
return KeyEventResult.ignored;
},
child: Builder(builder: (context) {
return TextField(
controller: controller,
enabled: enabled,
textAlign: num ? TextAlign.right : TextAlign.left,
keyboardType:
num ? const TextInputType.numberWithOptions(decimal: true) : null,
inputFormatters: num
? [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}'))]
: null,
onChanged: onChanged,
onSubmitted: (_) => onEnter?.call(),
style: TextStyle(
fontSize: AppDims.fsBody,
color: t.text,
fontFamily: num ? 'monospace' : null),
cursorColor: t.primary,
decoration: InputDecoration(
isCollapsed: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
hintText: hint,
hintStyle: TextStyle(fontSize: AppDims.fsBody, color: t.faint),
prefixText: money ? '¥' : null,
prefixStyle: TextStyle(fontSize: AppDims.fsBody, color: t.muted),
filled: false,
border: _b(Colors.transparent),
enabledBorder: _b(hasError ? t.danger : Colors.transparent),
focusedBorder: _b(t.primary),
),
);
}),
);
}
OutlineInputBorder _b(Color c) => OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderSide: BorderSide(color: c),
);
}
/// 只读单元格文本(.ro):系列/规格/成本价等出库只读列。
class RoCell extends StatelessWidget {
final String text;
final bool num;
final Color? color;
const RoCell(this.text, {super.key, this.num = false, this.color});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Text(text.isEmpty ? '' : text,
textAlign: num ? TextAlign.right : TextAlign.left,
style: TextStyle(
fontSize: AppDims.fsBody,
color: color ?? (text.isEmpty ? t.faint : t.muted),
fontFamily: num ? 'monospace' : null)),
);
}
}
/// 金额单元格(.amt):正常等宽粗体;[pending] 时橙色「待定价」。
class AmountCell extends StatelessWidget {
final double amount;
final bool pending;
const AmountCell({super.key, required this.amount, this.pending = false});
@override
Widget build(BuildContext context) {
final t = context.tokens;
if (pending) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text('待定价',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: AppDims.fsBody,
color: t.warn,
fontWeight: FontWeight.w600)),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text('¥${amount.toStringAsFixed(2)}',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: AppDims.fsBody,
color: t.heading,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w600)),
);
}
}
/// 行操作图标组(.gact-in):展开(可选)/复制/删除。
class RowActions extends StatelessWidget {
final bool? expanded; // null → 不显示展开
final VoidCallback? onToggleExpand;
final bool expandHighlight;
final VoidCallback onCopy;
final VoidCallback? onDelete;
const RowActions({
super.key,
this.expanded,
this.onToggleExpand,
this.expandHighlight = false,
required this.onCopy,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (expanded != null)
_icon(
expanded! ? LucideIcons.chevronUp : LucideIcons.chevronDown,
expandHighlight ? t.primary : t.muted,
onToggleExpand,
'选填项',
),
_icon(LucideIcons.copy, t.muted, onCopy, '复制本行'),
_icon(LucideIcons.trash2, onDelete == null ? t.faint : t.muted,
onDelete, '删除'),
],
);
}
// 紧凑图标按钮:24×24 命中区,不走 IconButton 的 48px 触摸目标(会撑破窄操作列)。
Widget _icon(IconData ic, Color color, VoidCallback? onTap, String tip) =>
Tooltip(
message: tip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: 24,
height: 24,
child: Icon(ic, size: 16, color: color),
),
),
);
}
/// 内联日期格(.datefield / .gci-date):点选或 Enter 打开统一日期下拉
/// (面板内含可输入框 + 三列滚轮,见 wheel_date_picker.dart),值等宽显示。
class DsDateCell extends StatefulWidget {
final String? value; // yyyy-MM-dd
final ValueChanged<String?> onChanged;
final FocusNode? focusNode;
final bool cell; // true=.gci-date 网格样式;false=.datefield 独立样式
final bool hasError;
final bool enabled;
final VoidCallback? onPicked; // 选完推进下一格
final bool Function({required bool backward})? onTab;
const DsDateCell({
super.key,
required this.value,
required this.onChanged,
this.focusNode,
this.cell = false,
this.hasError = false,
this.enabled = true,
this.onPicked,
this.onTab,
});
@override
State<DsDateCell> createState() => _DsDateCellState();
}
class _DsDateCellState extends State<DsDateCell> {
BuildContext? _anchorCtx; // 字段自身 ctx(Builder 捕获),下拉锚定于此
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();
}
}
@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;
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),
),
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: 15, color: t.faint),
]),
),
);
}),
);
}
}
// ── 键盘流:跨格推进 / 回退 ─────────────────────────────────────────────────
/// 从 [fromField] 之后找本行第一个空的必填格聚焦;本行满足则找后续行;
/// 末行末格则 [onAddRow](新行自动聚焦首格)。对齐原型 advance()。
void advanceFocus({
required int rowIndex,
required String fromField,
required int rowCount,
required List<String> fields, // 可聚焦字段顺序
required bool Function(String field) isRequired,
required bool Function(int row, String field) isEmpty,
required void Function(int row, String field) focusCell,
required VoidCallback onAddRow,
}) {
final start = fields.indexOf(fromField) + 1;
for (var k = start; k < fields.length; k++) {
if (isRequired(fields[k]) && isEmpty(rowIndex, fields[k])) {
focusCell(rowIndex, fields[k]);
return;
}
}
for (var i = rowIndex + 1; i < rowCount; i++) {
for (final f in fields) {
if (isRequired(f) && isEmpty(i, f)) {
focusCell(i, f);
return;
}
}
}
if (rowIndex == rowCount - 1) {
onAddRow();
} else {
focusCell(rowIndex + 1, fields.first);
}
}
/// 回退到上一格(本行前一格,或上一行末格)。对齐原型 retreat()。
void retreatFocus({
required int rowIndex,
required String fromField,
required List<String> fields,
required void Function(int row, String field) focusCell,
}) {
final k = fields.indexOf(fromField) - 1;
if (k >= 0) {
focusCell(rowIndex, fields[k]);
} else if (rowIndex > 0) {
focusCell(rowIndex - 1, fields.last);
}
}