import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'dart:math' as math; import 'package:flutter/material.dart'; import '../core/responsive/responsive.dart'; import '../core/theme/context_tokens.dart'; import '../core/theme/app_dims.g.dart'; import '../core/theme/app_fonts.dart'; import 'ds/m_card.dart'; import 'ds/m_sheet.dart'; /// 右侧滑入抽屉呈现(对齐原型 .drawer / .drawer-mask)。 /// useRootNavigator:false → 只覆盖内容区(分支 Navigator),不盖侧栏/顶栏。 const _kDrawerMask = Color(0x52000000); // ds-ignore: .drawer-mask 遮罩固定色(黑 32%) Future showOrderDetailDrawer( BuildContext context, { required WidgetBuilder builder, }) { // 窄屏:底部 sheet 形态(原型 m-stock-*-list openOrder 详情 sheet); // OrderDetailDrawer 自带「单号 + X」头部,title:null 只出 grip; // scrollable:false + 定界高 → 内部 Column/Expanded 布局照常工作。 if (context.isMobile) { return showMSheet( context, title: null, scrollable: false, builder: (ctx) => SizedBox( height: MediaQuery.of(ctx).size.height * 0.86, child: Builder(builder: builder), ), ); } return showGeneralDialog( context: context, useRootNavigator: false, barrierDismissible: true, barrierLabel: '关闭', // .drawer-mask scrim(抽屉遮罩固定色) barrierColor: _kDrawerMask, transitionDuration: const Duration(milliseconds: 250), pageBuilder: (ctx, _, __) { final w = math.min(660.0, MediaQuery.of(ctx).size.width * 0.94); return Align( alignment: Alignment.centerRight, child: Material( color: ctx.tokens.surface, elevation: 16, // 抽屉在 app_shell 的全局 SelectionArea 之外(独立 overlay)→ 自带一层,文字可选中复制。 child: SelectionArea( child: SizedBox( width: w, height: double.infinity, child: builder(ctx)), ), ), ); }, transitionBuilder: (ctx, anim, _, child) => SlideTransition( position: Tween(begin: const Offset(1, 0), end: Offset.zero) .animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)), child: child, ), ); } /// 抽屉明细行(对齐原型 .lines .lr,入库 6 列 / 出库管理员 8 列: /// 商品·编码 / 系列·规格 / 生产日期·批次号 / 数量 / 单价(/成本价/售价/总售价) / 利润)。 class OrderLine { final String name; final String code; final String series; final String spec; final String qty; final String price; // 已算好文案(或「待定价」) final String amount; // 出库管理员 6 列形态(2026-07 定价重设计):成本价 + 利润; // 两者非空时明细表渲染 数量/成本价/售价(price)/利润 四数值列,amount 不用。 final String? cost; final String? profit; final bool pending; // 单价<=0:单价/金额显示待定价样式 // 生产日期 / 批次号(2026-07-14,出入库单详情明细新增双行列) final String? productionDate; final String? batchNo; const OrderLine({ required this.name, required this.code, required this.series, required this.spec, required this.qty, required this.price, this.amount = '', this.cost, this.profit, this.pending = false, this.productionDate, this.batchNo, }); } /// 抽屉底部操作分组(对齐原型 .dact-group:标签 + 一排按钮)。 class DrawerActionGroup { final String label; final List buttons; const DrawerActionGroup(this.label, this.buttons); } /// 详情抽屉骨架(对齐原型 openDetail 布局): /// 头(单号+X) / 信息行(.drow) / 明细标题 / 明细表(.lines) / 合计(.ltotal) / 操作组(.dactions)。 class OrderDetailDrawer extends StatelessWidget { final String title; final List<({String label, Widget value})> infoRows; final String linesLabel; // 入库明细 / 出库明细 final List lines; final String totalText; // 合计金额(已格式化) final String? profitText; // 合计利润(仅出库·管理员传入;null 不渲染) final String priceLabel; // 单价列头(入库=进价(单瓶)/出库=售价) final String amountLabel; // 金额列头(入库=总进价/出库 operator=小计) final List actionGroups; const OrderDetailDrawer({ super.key, required this.title, required this.infoRows, required this.linesLabel, required this.lines, required this.totalText, this.profitText, this.priceLabel = '单价', this.amountLabel = '金额', required this.actionGroups, }); @override Widget build(BuildContext context) { final t = context.tokens; // 窄屏:sheet 形态布局(对齐原型 m-stock-*-list openOrder:drow 字段 + // 明细 pe-card + 合计 drow + 等分按钮行),桌面右滑抽屉布局不变。 if (context.isMobile) return _buildMobileSheet(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ── 头:单号 + 关闭 ──(.drawer-head) Container( padding: const EdgeInsets.fromLTRB(20, 18, 16, 18), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: t.border))), child: Row( children: [ Expanded( child: Text(title, style: TextStyle( fontSize: AppDims.fsH2, fontWeight: FontWeight.w700, color: t.heading)), ), IconButton( icon: Icon(LucideIcons.x, size: 22, color: t.muted), splashRadius: 20, onPressed: () => Navigator.of(context).pop(), ), ], ), ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ── 信息行(.drow)── for (final r in infoRows) _DrawerRow(label: r.label, value: r.value), // ── 明细标题 ── Padding( padding: const EdgeInsets.fromLTRB(0, 16, 0, 8), child: Text(linesLabel, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), ), _LinesTable( lines: lines, priceLabel: priceLabel, amountLabel: amountLabel), // ── 合计(.ltotal)── Padding( padding: const EdgeInsets.fromLTRB(4, 8, 4, 0), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Text('合计金额 ', style: TextStyle( fontSize: AppDims.fsBody, color: t.muted)), const SizedBox(width: 6), Text(totalText, style: TextStyle( fontSize: 18, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontWeight: FontWeight.w700, color: t.accent)), ], ), ), if (profitText != null) Padding( padding: const EdgeInsets.fromLTRB(4, 6, 4, 0), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Text('合计利润 ', style: TextStyle( fontSize: AppDims.fsBody, color: t.muted)), const SizedBox(width: 6), Text(profitText!, style: TextStyle( fontSize: AppDims.fsTitle, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontWeight: FontWeight.w700, color: profitText!.startsWith('-') ? t.danger : t.success)), ], ), ), // ── 操作组(.dactions)── if (actionGroups.isNotEmpty) const SizedBox(height: 18), for (var i = 0; i < actionGroups.length; i++) ...[ if (i > 0) const SizedBox(height: 14), _ActionGroup(group: actionGroups[i]), ], ], ), ), ), ], ); } // ── 窄屏 sheet 形态(原型 openOrder)──────────────────────────────────── Widget _buildMobileSheet(BuildContext context) { final t = context.tokens; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 头:单号 + X(grip 由 showMSheet 提供) Container( padding: const EdgeInsets.fromLTRB(16, 4, 8, 8), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: t.borderSubtle))), child: Row(children: [ Expanded( child: Text(title, style: TextStyle( fontSize: AppDims.fsTitle, fontWeight: FontWeight.w700, color: t.heading)), ), IconButton( icon: Icon(LucideIcons.x, size: 20, color: t.muted), splashRadius: 18, onPressed: () => Navigator.of(context).pop(), ), ]), ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 2, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final r in infoRows) _DrawerRow(label: r.label, value: r.value), // 明细卡(原型 pe-card:头行 图标+「入库明细 · N 项」+ 行列表) Container( margin: const EdgeInsets.only(top: 14), decoration: BoxDecoration( border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( padding: const EdgeInsets.fromLTRB(14, 11, 14, 11), decoration: BoxDecoration( border: Border( bottom: BorderSide(color: t.borderSubtle))), child: Row(children: [ Icon(LucideIcons.tableProperties, size: 15, color: t.primary), const SizedBox(width: 8), Text('$linesLabel · ${lines.length} 项', style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w600, color: t.heading)), ]), ), Padding( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 2), child: Column(children: [ for (var i = 0; i < lines.length; i++) _mLineRow(t, lines[i], i == lines.length - 1), ]), ), ], ), ), // 合计(原型 drow:label 左 / mono 值右) _mTotalRow(t, '合计金额', totalText, t.heading), if (profitText != null) _mTotalRow(t, '合计利润', profitText!, profitText!.startsWith('-') ? t.danger : t.success), // 操作按钮(原型:等分按钮行,无组标签) if (actionGroups.isNotEmpty) const SizedBox(height: 16), for (var g = 0; g < actionGroups.length; g++) ...[ if (g > 0) const SizedBox(height: 10), Row(children: [ for (var b = 0; b < actionGroups[g].buttons.length; b++) ...[ if (b > 0) const SizedBox(width: 10), Expanded(child: actionGroups[g].buttons[b]), ], ]), ], ], ), ), ), ], ); } /// 窄屏明细行(原型:左 商品名 + 系列·×数量 小字,中 生产日期/批次号双行列 /// (两值都空则不渲染,避免空占位),右 金额 mono / 待定价徽章)。 Widget _mLineRow(dynamic t, OrderLine l, bool last) { final hasDateBatch = (l.productionDate?.isNotEmpty ?? false) || (l.batchNo?.isNotEmpty ?? false); return Container( padding: const EdgeInsets.symmetric(vertical: 11), decoration: last ? null : BoxDecoration( border: Border(bottom: BorderSide(color: t.borderSubtle))), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l.name, style: TextStyle( fontSize: AppDims.fsBody, color: t.text, height: 1.45)), const SizedBox(height: 2), Text('${l.series} · ×${l.qty}', style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)), ], ), ), if (hasDateBatch) ...[ const SizedBox(width: 10), Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Text( (l.productionDate?.isNotEmpty ?? false) ? l.productionDate! : '—', style: TextStyle( fontSize: AppDims.fsSm, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, color: t.text)), Text((l.batchNo?.isNotEmpty ?? false) ? l.batchNo! : '—', style: TextStyle( fontSize: AppDims.fsXs, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, color: t.faint)), ], ), ], const SizedBox(width: 10), l.pending ? const DsTextIconBadge('待定价') : Text(l.amount.isNotEmpty ? l.amount : l.price, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.heading, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), ], ), ); } Widget _mTotalRow(dynamic t, String label, String value, Color color) { return Padding( padding: const EdgeInsets.symmetric(vertical: 11), child: Row(children: [ Text(label, style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)), const Spacer(), Text(value, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w700, color: color, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), ]), ); } } class _DrawerRow extends StatelessWidget { final String label; final Widget value; const _DrawerRow({required this.label, required this.value}); @override Widget build(BuildContext context) { final t = context.tokens; return Container( padding: const EdgeInsets.symmetric(vertical: 11), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: t.borderSubtle))), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)), const Spacer(), DefaultTextStyle( style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.text), child: value, ), ], ), ); } } class _LinesTable extends StatelessWidget { final List lines; final String priceLabel; final String amountLabel; const _LinesTable( {required this.lines, required this.priceLabel, required this.amountLabel}); // 任一行带成本/利润 → 出库管理员 6 列形态 bool get _withProfit => lines.any((l) => l.cost != null || l.profit != null); @override Widget build(BuildContext context) { final t = context.tokens; return Container( decoration: BoxDecoration( border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rMd), ), child: Column( children: [ // 表头 Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), decoration: BoxDecoration( color: t.thBg, borderRadius: const BorderRadius.vertical(top: Radius.circular(6)), ), child: _lineRow( context, c1: Text('商品 · 编码', style: _hStyle(t), overflow: TextOverflow.ellipsis), c2: Text('系列 / 规格', style: _hStyle(t)), cDate: Text('生产日期 / 批次号', style: _hStyle(t)), c3: Text('数量', textAlign: TextAlign.right, style: _hStyle(t)), c4: _withProfit ? Text('成本价', textAlign: TextAlign.right, style: _hStyle(t)) : Text(priceLabel, textAlign: TextAlign.right, style: _hStyle(t)), c5: _withProfit ? Text('售价', textAlign: TextAlign.right, style: _hStyle(t)) : Text(amountLabel, textAlign: TextAlign.right, style: _hStyle(t)), c6: _withProfit ? Text('总售价', textAlign: TextAlign.right, style: _hStyle(t)) : null, c7: _withProfit ? Text('利润', textAlign: TextAlign.right, style: _hStyle(t)) : null, ), ), for (var i = 0; i < lines.length; i++) Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), decoration: BoxDecoration( border: i == 0 ? null : Border(top: BorderSide(color: t.borderSubtle)), ), child: _lineRow( context, c1: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(lines[i].name, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.text)), Text(lines[i].code, style: TextStyle( fontSize: AppDims.fsXs, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, color: t.faint)), ], ), c2: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(lines[i].series, style: TextStyle(fontSize: AppDims.fsSm, color: t.text)), Text(lines[i].spec, style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), ], ), cDate: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( (lines[i].productionDate?.isNotEmpty ?? false) ? lines[i].productionDate! : '—', style: TextStyle( fontSize: AppDims.fsSm, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, color: t.text)), Text( (lines[i].batchNo?.isNotEmpty ?? false) ? lines[i].batchNo! : '—', style: TextStyle( fontSize: AppDims.fsXs, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, color: t.faint)), ], ), c3: Text(lines[i].qty, textAlign: TextAlign.right, style: const TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), c4: _withProfit ? _priceCell( context, lines[i].cost ?? '—', lines[i].pending) : _priceCell(context, lines[i].price, lines[i].pending), c5: _withProfit ? _priceCell(context, lines[i].price, lines[i].pending) : _priceCell(context, lines[i].amount, lines[i].pending, accent: true), c6: _withProfit ? _priceCell(context, lines[i].amount, lines[i].pending, accent: true) : null, c7: _withProfit ? _profitCell( context, lines[i].profit ?? '—', lines[i].pending) : null, ), ), ], ), ); } TextStyle _hStyle(dynamic t) => TextStyle( fontSize: AppDims.fsSm, color: t.muted, fontWeight: FontWeight.w600); Widget _priceCell(BuildContext context, String text, bool pending, {bool accent = false}) { final t = context.tokens; if (pending) { return Text(text, textAlign: TextAlign.right, style: TextStyle( fontSize: AppDims.fsXs, color: t.warn, fontWeight: FontWeight.w600)); } return Text(text, textAlign: TextAlign.right, style: TextStyle( fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontWeight: accent ? FontWeight.w700 : FontWeight.w400, color: accent ? t.text : t.text)); } // 6 列布局(入库,对齐原型 grid 1fr 110 72 40 64 78);c6/c7 非空时为出库 // 8 列形态(商品/系列规格/生产日期批次号/数量/成本价/售价/总售价/利润,数值列等宽收窄)。 Widget _lineRow(BuildContext context, {required Widget c1, required Widget c2, required Widget cDate, required Widget c3, required Widget c4, required Widget c5, Widget? c6, Widget? c7}) { final wide = c6 != null; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: c1), const SizedBox(width: 8), // 6 列(入库)时中间列加宽左移:商品列不再吃掉全部余宽, // 「进价(单瓶)」表头一行放得下(2026-07-03 用户反馈)。 // 2026-07-14:新增「生产日期/批次号」列,入库侧挤压 124→110、88→78 腾位。 SizedBox(width: wide ? 100 : 110, child: c2), const SizedBox(width: 8), SizedBox(width: 72, child: cDate), const SizedBox(width: 8), SizedBox(width: wide ? 32 : 40, child: c3), const SizedBox(width: 8), SizedBox(width: wide ? 62 : 78, child: c4), const SizedBox(width: 8), SizedBox(width: wide ? 62 : 78, child: c5), if (c6 != null) ...[ const SizedBox(width: 8), SizedBox(width: 62, child: c6), ], if (c7 != null) ...[ const SizedBox(width: 8), SizedBox(width: 62, child: c7), ], ], ); } // 利润列:负数染 danger,正常 success;待定价沿 warn 样式 Widget _profitCell(BuildContext context, String text, bool pending) { final t = context.tokens; if (pending) { return Text(text, textAlign: TextAlign.right, style: TextStyle( fontSize: AppDims.fsXs, color: t.warn, fontWeight: FontWeight.w600)); } return Text(text, textAlign: TextAlign.right, style: TextStyle( // 明细数值列缩小一号(2026-07-03 用户反馈:列多时拥挤) fontSize: AppDims.fsSm, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, fontWeight: FontWeight.w600, color: text.startsWith('-') ? t.danger : t.success)); } } class _ActionGroup extends StatelessWidget { final DrawerActionGroup group; const _ActionGroup({required this.group}); @override Widget build(BuildContext context) { final t = context.tokens; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(group.label, style: TextStyle( fontSize: AppDims.fsXs, color: t.muted, fontWeight: FontWeight.w600, letterSpacing: 0.4)), const SizedBox(height: 8), Wrap(spacing: 10, runSpacing: 10, children: group.buttons), ], ); } }