// 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/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 'write_guard.dart'; import '../core/theme/app_fonts.dart'; import 'ds/ds_toast.dart'; /// 打开往来单位详情抽屉。应收/应付由列表页的 partnerBalancesProvider 传入; /// 近期单据抽屉内经 partnerRecentDocsProvider 拉取。 Future showPartnerDetailDrawer( BuildContext context, { required Partner partner, ({double recv, double pay})? balance, VoidCallback? onEdit, }) { return showGeneralDialog( 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(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)), ), ); /// 原型 .drow:label(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, });