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 deskActions; /// 窄屏页头动作(主操作 + 溢出菜单)。 final List 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 fields; // 4 个 DocField final Widget remark; final String summary; // 折叠态摘要(仓库 · 往来单位 · 日期) const DocHeadCard({ super.key, required this.fields, required this.remark, required this.summary, }); @override State createState() => _DocHeadCardState(); } class _DocHeadCardState extends State { 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 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 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? 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 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 createState() => _DsDateCellState(); } class _DsDateCellState extends State { // 浮层锚定到字段下方(对齐原型 datewheel.js:pop.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 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 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); } }