Files
jiu/client/lib/screens/shared/order_form_shell.dart
T
wangjia 979ca3c83b feat(client): 出库明细成本/售价/利润三列重设计 + 建单利润列 + 入库列头统一
- 出库详情抽屉(管理员):数量/成本价/售价/利润 + 合计金额/合计利润 双行;
  operator 见 售价/小计(服务端抹零+前端隐藏双保险);利润负数 danger
- 出库建单:商品名称下带编码(字体同系列列);金额列→利润列(实时);
  底栏合计利润(仅管理员);进价列仅管理员;提交发新 key(cost_price/sale_price)
- 入库建单列头:进价(单瓶)/ 参考售价 / 总进价
- 退单弹窗金额改售价口径(与冲应收一致);打印保留待定价回退成本兼容
- models 字段随后端消歧改名;golden 重打;新增抽屉双形态 widget 测试

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

980 lines
31 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: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 打开日历,值等宽显示。
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> {
// 浮层锚定到字段下方(对齐原型 datewheel.jspop.style.top = field.bottom + 4),
// 非居中弹窗,无遮罩——点字段外任意处收起(TapRegion.onTapOutside)。
final _link = LayerLink();
OverlayEntry? _entry;
bool get _open => _entry != null;
@override
void dispose() {
_removeOverlay();
super.dispose();
}
void _toggle() {
if (_open) {
_close();
} else {
_openOverlay();
}
}
void _openOverlay() {
if (_open || !widget.enabled) return;
final init = parseYmd(widget.value) ?? DateTime.now();
_entry = OverlayEntry(
builder: (_) => Positioned(
width: 248,
child: CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: Alignment.bottomLeft,
followerAnchor: Alignment.topLeft,
offset: const Offset(0, 4),
child: TapRegion(
onTapOutside: (_) => _close(),
child: WheelDatePanel(
initial: init,
onCommit: (d) {
widget.onChanged(formatYmd(d));
_close();
widget.onPicked?.call();
},
),
),
),
),
);
Overlay.of(context).insert(_entry!);
setState(() {});
}
void _close() {
_removeOverlay();
if (mounted) setState(() {});
}
void _removeOverlay() {
_entry?.remove();
_entry = null;
}
@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 CompositedTransformTarget(
link: _link,
child: 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) {
_toggle();
return KeyEventResult.handled;
}
if (k == LogicalKeyboardKey.escape) {
if (_open) {
_close();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
if (k == LogicalKeyboardKey.tab) {
final back = HardwareKeyboard.instance.isShiftPressed;
if (_open) _close();
final handled = widget.onTab?.call(backward: back) ?? false;
return handled ? KeyEventResult.handled : KeyEventResult.ignored;
}
return KeyEventResult.ignored;
},
child: Builder(builder: (context) {
final active = Focus.of(context).hasFocus || _open;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.enabled
? () {
widget.focusNode?.requestFocus();
_toggle();
}
: 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 ? 'monospace' : 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);
}
}