Files
jiu/client/lib/widgets/order_detail_drawer.dart
T

704 lines
27 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 '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<T?> showOrderDetailDrawer<T>(
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<T>(
context,
title: null,
scrollable: false,
builder: (ctx) => SizedBox(
height: MediaQuery.of(ctx).size.height * 0.86,
child: Builder(builder: builder),
),
);
}
return showGeneralDialog<T>(
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<Offset>(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<Widget> 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<OrderLine> lines;
final String totalText; // 合计金额(已格式化)
final String? profitText; // 合计利润(仅出库·管理员传入;null 不渲染)
final String priceLabel; // 单价列头(入库=进价(单瓶)/出库=售价)
final String amountLabel; // 金额列头(入库=总进价/出库 operator=小计)
final List<DrawerActionGroup> 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 openOrderdrow 字段 +
// 明细 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: [
// 头:单号 + Xgrip 由 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),
]),
),
],
),
),
// 合计(原型 drowlabel 左 / 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<OrderLine> 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),
],
);
}
}