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
This commit is contained in:
wangjia
2026-07-03 09:58:14 +08:00
parent ca7595b113
commit 6238b86dcb
286 changed files with 16831 additions and 11136 deletions
@@ -0,0 +1,963 @@
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 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.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(),
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);
}
}