Files
jiu/client/lib/widgets/partner_detail_drawer.dart
T
wangjia be9afcf68d feat(client): 移动壳基建——底部 5 tab + 我的 hub + 统一底部 sheet + 移动组件四件套
对齐移动原型体系(m-*.html)的 Stage 0 共享基建,桌面形态零改动:

- 导航:router 新增 branch 9(/me 我的 + /me/license 授权屏);窄屏 tab 根
  (库存/入库/出库/财务/我的)出底部 MTabBar,二级屏隐藏 tabbar + 顶栏返回箭头
  (PopScope 拦系统返回键防退 app);窄屏 Drawer/汉堡删除;窄屏顶栏对齐 m-top
  (标题 + 主题/通知铃/头像→我的)
- sheet:showMSheet(grip/标题/86vh/键盘避让)+ showAdaptiveSheet;六个共享
  弹层呈现函数加窄屏 sheet 分支(订单/往来/财务往来/商品编辑抽屉 + 退单/登记收支
  dialog 的 asSheet 形态)
- 徽章:DsBadge/StatusPill/StatusBadge 加图标变体(默认圆点零漂移)+
  status_icon_map 转录原型 BADGE_ICON 28 词
- 组件:MKpiGrid(2×2 可点 KPI)/ MSearchRow(搜索+状态钮+详搜钮)/
  MHubGroup(hub 入口组)/ showThemeSheet(主题外观 sheet)
- 授权:_LicensePanel/_SettingsCard 抽取为公共 LicensePanel/SettingsCard,
  窄屏授权跳转 /me/license
- golden:app_shell_mobile + me 各三主题(390×844@2x);全量 259 测试绿,
  check_ds_code / check-l1-sync 闸过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:56:04 +08:00

316 lines
12 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.
// widgets/partner_detail_drawer.dart — 往来单位详情抽屉(镜像原型 partners.html openDetail)。
// 结构照 atoms .drawer/.dsec/.drow/.dli:联系信息 / 应收应付(应收染 success、应付染 accent
// / 近期单据(财务流水,已覆盖入库应付、出库应收与收付款)/ 底部 对账单+编辑。
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/responsive/responsive.dart';
import '../core/theme/app_chrome.g.dart';
import '../core/theme/app_dims.g.dart';
import '../core/theme/context_tokens.dart';
import '../models/finance.dart';
import '../models/partner.dart';
import '../providers/partner_provider.dart';
import 'ds/ds_atoms.dart';
import 'ds/m_sheet.dart';
import 'write_guard.dart';
import '../core/theme/app_fonts.dart';
import 'ds/ds_toast.dart';
/// 打开往来单位详情抽屉。应收/应付由列表页的 partnerBalancesProvider 传入;
/// 近期单据抽屉内经 partnerRecentDocsProvider 拉取。
Future<void> showPartnerDetailDrawer(
BuildContext context, {
required Partner partner,
({double recv, double pay})? balance,
VoidCallback? onEdit,
}) {
// 窄屏:底部 sheet 形态(原型 m-partners openP 详情 sheet
if (context.isMobile) {
return showMSheet<void>(
context,
title: null,
scrollable: false,
builder: (ctx) => SizedBox(
height: MediaQuery.of(ctx).size.height * 0.86,
child: _PartnerDetailDrawer(
partner: partner, balance: balance, onEdit: onEdit),
),
);
}
return showGeneralDialog<void>(
context: context,
useRootNavigator: false,
barrierDismissible: true,
barrierLabel: '关闭',
barrierColor: AppChrome.scrim, // .drawer-mask --scrim
transitionDuration: const Duration(milliseconds: 250),
pageBuilder: (ctx, _, __) {
final w = math.min(480.0, MediaQuery.of(ctx).size.width * 0.94);
return Align(
alignment: Alignment.centerRight,
child: Material(
color: ctx.tokens.surface,
elevation: 16,
child: SelectionArea(
child: SizedBox(
width: w,
height: double.infinity,
child: _PartnerDetailDrawer(
partner: partner, balance: balance, onEdit: onEdit),
),
),
),
);
},
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,
),
);
}
/// 金额:原型预格式化字符串风格 ¥x,xxx(整数无小数,非整保留两位)。
String fmtYuan(double v) {
final money = v == v.roundToDouble()
? NumberFormat.decimalPattern().format(v.round())
: NumberFormat('#,##0.00').format(v);
return '¥$money';
}
class _PartnerDetailDrawer extends ConsumerWidget {
final Partner partner;
final ({double recv, double pay})? balance;
final VoidCallback? onEdit;
const _PartnerDetailDrawer(
{required this.partner, this.balance, this.onEdit});
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = context.tokens;
final docs = ref.watch(partnerRecentDocsProvider(partner.id));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── .drawer-head:名称 + X ──
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.border))),
child: Row(
children: [
Expanded(
child: Text(partner.name,
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
color: t.heading)),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(LucideIcons.x, size: 18, color: t.muted),
tooltip: '关闭',
),
],
),
),
// ── .drawer-body ──
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_dsec(t, '联系信息', first: true),
_drow(t, '类型', badge: partnerTypeBadge(partner)),
_drow(t, '联系人',
text: (partner.contact?.isNotEmpty == true)
? partner.contact!
: ''),
_drow(t, '电话',
text: (partner.phone?.isNotEmpty == true)
? partner.phone!
: '',
mono: true),
_drow(t, '地址',
text: (partner.address?.isNotEmpty == true)
? partner.address!
: '',
wrap: true),
_dsec(t, '应收 / 应付'),
_drow(t, '应收账款',
text: fmtYuan(balance?.recv ?? 0),
mono: true,
color: t.success),
_drow(t, '应付账款',
text: fmtYuan(balance?.pay ?? 0),
mono: true,
color: t.accent),
_dsec(t, '近期单据'),
docs.when(
loading: () => const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))),
),
error: (e, _) => Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text('单据加载失败',
style:
TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
),
data: (rows) => rows.isEmpty
? Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text('暂无往来单据',
style: TextStyle(
color: t.muted, fontSize: AppDims.fsSm)),
)
: Column(children: [for (final r in rows) _dli(t, r)]),
),
],
),
),
),
// ── 底部操作(原型:对账单 / 编辑)──
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration:
BoxDecoration(border: Border(top: BorderSide(color: t.border))),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
DsButton('对账单', small: true, onPressed: () {
showDsToast(context, '对账单功能即将上线');
}),
const SizedBox(width: 10),
if (onEdit != null)
WriteGuard(
child: DsButton('编辑', small: true, onPressed: () {
Navigator.of(context).pop();
onEdit!();
}),
),
],
),
),
],
);
}
/// 原型 .dsec:区块小标题(fs-xs muted 600,上距 18)。
Widget _dsec(dynamic t, String label, {bool first = false}) => Padding(
padding: EdgeInsets.only(top: first ? 0 : 18, bottom: 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text(label,
style: TextStyle(
fontSize: AppDims.fsXs,
color: t.muted,
fontWeight: FontWeight.w600,
letterSpacing: .4)),
),
);
/// 原型 .drowlabel(muted) + b(text 600)pad 11 0,下边 border-subtle。
Widget _drow(dynamic t, String label,
{String? text,
Widget? badge,
bool mono = false,
bool wrap = false,
Color? color}) {
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(),
if (badge != null)
badge
else
ConstrainedBox(
// 原型地址行 max-width:200 右对齐显示完整地址
constraints: BoxConstraints(maxWidth: wrap ? 200 : 260),
child: Text(text ?? '',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: color ?? t.text,
fontFamily: mono ? 'monospace' : null)),
),
],
),
);
}
/// 原型 .dli:近期单据行(左 标题+small 日期,右 金额 mono 600)。
Widget _dli(dynamic t, FinanceRecord r) {
// 收款/付款是清账方向,按原型示例带负号
final out = r.type == 'receipt' || r.type == 'payment';
return Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.borderSubtle))),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(r.docTitle,
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
[
if ((r.recordDate ?? '').isNotEmpty)
r.recordDate!.split('T').first,
if ((r.remark ?? '').isNotEmpty) r.remark!,
].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsXs,
color: t.faint,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
),
],
),
),
const SizedBox(width: 12),
Text('${out ? '-' : ''}${fmtYuan(r.amount)}',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.heading,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
],
),
);
}
}
/// 类型徽章(原型 b-供应商=info / b-客户=accent / b-两者=warn),列表/卡片/抽屉共用。
DsBadge partnerTypeBadge(Partner p) => DsBadge(p.typeLabel,
tone: switch (p.typeLabel) {
'两者' => DsBadgeTone.warn,
'客户' => DsBadgeTone.accent,
_ => DsBadgeTone.info,
});