Files
jiu/client/lib/widgets/product_editor_drawer.dart
T
wangjia 65c672b4ad feat(client): 库存三态 UI——抽屉挂售 switch + 三态徽章/筛选/已售 KPI 卡
- 新组件 DsSwitch(原型 atoms .switch 对照,L1 已登记:38×22 pill,on=primary)
- 商品抽屉状态行:三态徽章后跟挂售 switch(在售⇄库存 即时生效,已售只读,
  readonly 禁用),桌面 drawer / 移动 sheet 同组件
- 列表状态列/卡片徽章三态化(在售绿/库存蓝/已售灰,icons BADGE_ICON 补两词);
  状态筛选改「全部/在售/库存/已售」走服务端;预警缺货退出状态维度由数量染色承担
- KPI 第四卡「缺货预警」→「已售」(sold_count,点击筛选已售)
- 原型同步:atoms/index 登记 switch 与 b-库存/b-已售、inventory 两屏三态数据、
  抽屉状态行 switch;12 道 ds 闸 + L1 同源闸全过
- golden 重录 ×9(三态徽章 + 已售卡形态)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 13:35:19 +08:00

798 lines
28 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/product_editor_drawer.dart — 商品编辑抽屉(镜像原型 product-editor.js + atoms.css .pe-*)。
// 库存屏行点击 / 眼睛图标打开:商品信息(.drow) + 商品图片(3 槽 .pe-gallery) + 商品介绍(.pe-pick/.pe-text)。
// 外壳对齐 .drawer:宽 480(≤94vw) / 右滑入 250ms / mask=--scrim。
import 'dart:math' as math;
import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/auth/auth_state.dart';
import '../core/config/app_config.dart';
import '../core/utils/dialog_util.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/product.dart';
import '../models/product_image.dart';
import '../providers/inventory_provider.dart' show inventoryRepositoryProvider;
import '../providers/product_option_provider.dart';
import '../providers/product_provider.dart';
import '../providers/shop_provider.dart' show shopInfoProvider;
import 'ds/ds_atoms.dart';
import 'ds/ds_switch.dart';
import 'ds/grid_combo_cell.dart';
import 'ds/m_sheet.dart';
import 'searchable_option_field.dart';
import 'fullscreen_image_viewer.dart';
import '../core/utils/label_data.dart';
import 'label_preview_dialog.dart';
import 'write_guard.dart';
import 'ds/ds_toast.dart';
/// 打开商品编辑抽屉。库存上下文(当前库存/成本价/状态)由调用方传入,
/// 商品档案(编码/系列/规格/分类/图片/介绍)抽屉内 getDetail 拉取。
Future<void> showProductEditorDrawer(
BuildContext context, {
required int productId,
double? qty,
String? unit,
double? cost,
String? status, // 在售 / 库存 / 已售(三态显示标签)
int? inventoryId, // 库存行 id:非空时状态行显示挂售 switch
String? inventoryStatus, // stock / on_sale / soldswitch 初始态)
VoidCallback? onStatusChanged, // switch 切换成功后回调(列表刷新)
}) {
// 窄屏:底部 sheet 形态(原型 m-inventory openItem 内嵌公开页编辑)
if (context.isMobile) {
return showMSheet<void>(
context,
title: null,
scrollable: false,
builder: (ctx) => SizedBox(
height: MediaQuery.of(ctx).size.height * 0.86,
child: _ProductEditorDrawer(
productId: productId,
qty: qty,
unit: unit,
cost: cost,
status: status,
inventoryId: inventoryId,
inventoryStatus: inventoryStatus,
onStatusChanged: onStatusChanged),
),
);
}
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: _ProductEditorDrawer(
productId: productId,
qty: qty,
unit: unit,
cost: cost,
status: status,
inventoryId: inventoryId,
inventoryStatus: inventoryStatus,
onStatusChanged: onStatusChanged),
),
),
),
);
},
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,
),
);
}
/// 原型 PE_TILE3 个图片槽位标题。
const _peTiles = ['主图', '细节图', '场景图'];
class _ProductEditorDrawer extends ConsumerStatefulWidget {
final int productId;
final double? qty;
final String? unit;
final double? cost;
final String? status;
final int? inventoryId;
final String? inventoryStatus;
final VoidCallback? onStatusChanged;
const _ProductEditorDrawer({
required this.productId,
this.qty,
this.unit,
this.cost,
this.status,
this.inventoryId,
this.inventoryStatus,
this.onStatusChanged,
});
@override
ConsumerState<_ProductEditorDrawer> createState() =>
_ProductEditorDrawerState();
}
class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> {
Product? _product;
Object? _error;
bool _uploading = false;
bool _saving = false;
// 库存三态挂售 switchstock⇄on_salesold 只读无 switch
late String? _invStatus = widget.inventoryStatus;
bool _statusSaving = false;
Future<void> _toggleOnSale(bool on) async {
final id = widget.inventoryId;
if (id == null || _statusSaving) return;
final next = on ? 'on_sale' : 'stock';
final prev = _invStatus;
setState(() {
_invStatus = next;
_statusSaving = true;
});
try {
await ref.read(inventoryRepositoryProvider).updateStatus(id, next);
widget.onStatusChanged?.call();
} catch (e) {
if (mounted) {
setState(() => _invStatus = prev);
showDsToast(context, '状态更新失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _statusSaving = false);
}
}
// 介绍选择态(.pe-pick / .pe-text
String? _introTitle; // 已选介绍库标题;null → 占位「从介绍库选择…」
int? _introDocId;
final _introCtrl = TextEditingController();
final _introFocus = FocusNode();
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_introCtrl.dispose();
_introFocus.dispose();
super.dispose();
}
Future<void> _load() async {
try {
final p =
await ref.read(productRepositoryProvider).getDetail(widget.productId);
if (!mounted) return;
setState(() {
_product = p;
_introTitle = p.descriptionDocTitle;
_introCtrl.text = p.description ?? '';
});
} catch (e) {
if (mounted) setState(() => _error = e);
}
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final p = _product;
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(p?.name ?? '商品',
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w600,
color: t.heading),
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 10),
DsButton('预览',
icon: LucideIcons.eye,
small: true,
onPressed: p == null || p.publicId.isEmpty
? null
: () => context.push('/product/${p.publicId}')),
// 打印标签(同入库管理打印标签;移动端无打印——用户拍板)
if (!context.isMobile) ...[
const SizedBox(width: 10),
DsButton('打印标签',
icon: LucideIcons.printer,
small: true,
onPressed: p == null ? null : () => _printLabel(p)),
],
const SizedBox(width: 10),
InkWell(
onTap: () => Navigator.of(context).pop(),
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Icon(LucideIcons.x, size: 22, color: t.muted),
),
],
),
),
// ── .drawer-body ──
Expanded(
child: _error != null
? Center(
child: Text('加载失败:$_error',
style:
TextStyle(fontSize: AppDims.fsBody, color: t.danger)))
: p == null
? Center(
child: SizedBox(
width: 30,
height: 30,
child: CircularProgressIndicator(
strokeWidth: 3, color: t.primary)))
: SingleChildScrollView(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_peCard(t, LucideIcons.table, '商品信息',
body: _infoBody(t, p),
bodyPadding: const EdgeInsets.symmetric(
horizontal: 13, vertical: 2)),
_peCard(t, LucideIcons.image, '商品图片',
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_gallery(t, p),
const SizedBox(height: 9),
Text('建议主图为白底产品图,可再加细节图(瓶身/防伪)与场景图。',
style: TextStyle(
fontSize: AppDims.fsXs,
color: t.faint,
height: 1.6)),
],
)),
_peCard(t, LucideIcons.menu, '商品介绍 / 卖点',
manage: () => context.push('/products?tab=intro'),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_introPick(t),
_introText(t),
],
)),
// ── .pe-foot ──
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
DsButton('取消',
onPressed: () =>
Navigator.of(context).pop()),
const SizedBox(width: 10),
WriteGuard(
child: DsButton('保存',
icon: LucideIcons.check,
variant: DsBtnVariant.primary,
onPressed: _saving ? null : _save),
),
],
),
),
],
),
),
),
],
);
}
// ── .pe-card:边框 r-lg 卡,.pe-h 头(图标块+标题+管理入口) + .pe-b 体 ──
Widget _peCard(
dynamic t,
IconData icon,
String title, {
required Widget body,
EdgeInsets bodyPadding = const EdgeInsets.all(13),
VoidCallback? manage,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 14),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 11),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.borderSubtle))),
child: Row(
children: [
Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: t.brand50,
borderRadius: BorderRadius.circular(AppDims.rSm)),
alignment: Alignment.center,
child: Icon(icon, size: 14, color: t.primary),
),
const SizedBox(width: 8),
Text(title,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.heading)),
if (manage != null) ...[
const Spacer(),
InkWell(
onTap: manage,
child: Text('管理介绍库',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.primary)),
),
],
],
),
),
Padding(padding: bodyPadding, child: body),
],
),
);
}
// ── 商品信息 .drow 行(条件行,末行无底线,对齐 product-editor.js infoRows ──
Widget _infoBody(dynamic t, Product p) {
final rows = <(String, Widget)>[];
Widget b(String s, {bool mono = false}) => Text(s,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.text,
fontFamily: mono ? 'monospace' : null));
if (p.code.isNotEmpty) rows.add(('编码', b(p.code, mono: true)));
if ((p.series ?? '').isNotEmpty) rows.add(('系列', b(p.series!)));
if ((p.spec ?? '').isNotEmpty) rows.add(('规格', b(p.spec!)));
if ((p.categoryName ?? '').isNotEmpty) rows.add(('分类', b(p.categoryName!)));
if (widget.qty != null) {
rows.add((
'当前库存',
b('${_fmtQty(widget.qty!)} ${widget.unit ?? p.unit}'.trim())
));
}
// 成本仅管理员可见(与库存列表/出库同口径);p.purchasePrice 兜底也要一并门控
final isAdmin = ref.watch(isAdminProvider);
final cost = widget.cost ?? p.purchasePrice;
if (isAdmin && cost != null && cost > 0) {
rows.add(('成本价(单瓶)', b(_fmtMoney(cost))));
if (widget.qty != null) {
rows.add(('总成本价', b(_fmtMoney(widget.qty! * cost))));
}
}
// 状态行:三态徽章 + 挂售 switch(在售⇄库存 即时生效;已售只读;readonly 禁用)
final invStatus = _invStatus;
final statusLabel = switch (invStatus) {
'on_sale' => '在售',
'sold' => '已售',
'stock' => '库存',
_ => widget.status ?? '',
};
if (statusLabel.isNotEmpty) {
final badge = DsBadge(statusLabel,
tone: switch (statusLabel) {
'在售' => DsBadgeTone.ok,
'已售' => DsBadgeTone.muted,
'库存' => DsBadgeTone.info,
'缺货' => DsBadgeTone.danger,
'预警' => DsBadgeTone.warn,
_ => DsBadgeTone.ok,
});
final canToggle = widget.inventoryId != null &&
invStatus != null &&
invStatus != 'sold' &&
!ref.watch(isReadonlyProvider);
rows.add((
'状态',
Row(mainAxisSize: MainAxisSize.min, children: [
badge,
if (widget.inventoryId != null && invStatus != null && invStatus != 'sold') ...[
const SizedBox(width: 10),
DsSwitch(
value: invStatus == 'on_sale',
onChanged: canToggle && !_statusSaving ? _toggleOnSale : null,
),
],
])
));
}
return Column(
children: [
for (var i = 0; i < rows.length; i++)
Container(
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration(
border: i == rows.length - 1
? null
: Border(bottom: BorderSide(color: t.borderSubtle))),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(rows[i].$1,
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
rows[i].$2,
],
),
),
],
);
}
// ── .pe-gallery3 槽 86×106,空=虚线+加号+槽名,有图=图+右上删+底部槽名 ──
Widget _gallery(dynamic t, Product p) {
final imgs = [...p.images]..sort((a, b) => a.sortOrder - b.sortOrder);
return Wrap(
spacing: 9,
runSpacing: 9,
children: [
for (var i = 0; i < _peTiles.length; i++)
_tile(t, i, i < imgs.length ? imgs[i] : null, imgs),
],
);
}
Widget _tile(dynamic t, int i, ProductImage? img, List<ProductImage> imgs) {
final has = img != null;
return SizedBox(
width: 86,
height: 106,
child: Stack(
fit: StackFit.expand,
children: [
InkWell(
onTap: has ? null : (_uploading ? null : _pickAndUpload),
// 双击已有图片 → 全屏预览(多图可翻页)
onDoubleTap: has
? () => showFullscreenImages(
context,
[for (final m in imgs) AppConfig.baseUrl + m.url],
initialIndex: imgs.indexOf(img),
)
: null,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: has ? null : t.bg,
border: Border.all(
color: t.border,
width: 1.5,
style: has ? BorderStyle.solid : BorderStyle.none),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
// 空槽虚线边框:Flutter 无原生 dashed,用 CustomPaint 描
child: has
? Image.network(AppConfig.baseUrl + img.url,
fit: BoxFit.cover)
: CustomPaint(
painter: _DashedBorderPainter(
color: t.border,
radius: AppDims.rMd,
strokeWidth: 1.5),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.plus, size: 20, color: t.faint),
const SizedBox(height: 5),
Text(_peTiles[i],
style: TextStyle(
fontSize: AppDims.fsXs, color: t.faint)),
],
),
),
),
),
if (has) ...[
// .cap 底部槽名(scrim 底白字)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 1),
color: AppChrome.scrim,
alignment: Alignment.center,
child: Text(_peTiles[i],
style:
TextStyle(fontSize: AppDims.fsXs, color: t.onPrimary)),
),
),
// .rm 右上删除
Positioned(
top: 3,
right: 3,
child: InkWell(
onTap: () => _removeImage(img),
child: Container(
width: 18,
height: 18,
decoration:
BoxDecoration(color: t.danger, shape: BoxShape.circle),
alignment: Alignment.center,
child: Icon(LucideIcons.x, size: 11, color: t.onPrimary),
),
),
),
],
],
),
);
}
// ── .pe-pick:从介绍库选择——搜索下拉(GridComboCell 表单形态,供应商选择器同款;
// 原型为普通菜单,搜索为用户要求的增强,差异记 design/CONTRACT.md)──
Widget _introPick(dynamic t) {
final docs =
ref.watch(productDescriptionDocListProvider).valueOrNull ?? const [];
// 商品上只存标题快照(无 doc id):编辑已有商品时按标题回配选中态
final selectedId = _introDocId ??
docs.where((d) => d.title == _introTitle).map((d) => d.id).firstOrNull;
return GridComboCell(
focusNode: _introFocus,
hint: _introTitle ?? '从介绍库选择…',
searchHint: '搜索介绍标题…',
showCount: true,
options: [for (final d in docs) OptionItem(id: d.id, name: d.title)],
selectedId: selectedId,
onChanged: (id) {
final doc = docs.where((d) => d.id == id).firstOrNull;
if (doc == null) return;
setState(() {
_introTitle = doc.title;
_introDocId = doc.id;
_introCtrl.text = doc.content ?? '';
});
},
);
}
// ── .pe-text:介绍正文 ──
Widget _introText(dynamic t) {
return Container(
margin: const EdgeInsets.only(top: 10),
constraints: const BoxConstraints(minHeight: 84),
child: TextField(
controller: _introCtrl,
maxLines: null,
minLines: 3,
style: TextStyle(fontSize: AppDims.fsBody, color: t.text, height: 1.7),
decoration: InputDecoration(
isDense: true,
hintText: '一句话卖点 + 商品介绍,可在选用后直接编辑…',
hintStyle: TextStyle(fontSize: AppDims.fsBody, color: t.faint),
contentPadding:
const EdgeInsets.symmetric(horizontal: 11, vertical: 10),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDims.rMd),
borderSide: BorderSide(color: t.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDims.rMd),
borderSide: BorderSide(color: t.primary),
),
),
),
);
}
// ── 图片上传/删除(沿用商品详情页的取图管线) ──
bool get _isMobilePlatform =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
Future<void> _pickAndUpload() async {
if (_isMobilePlatform) {
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(LucideIcons.camera),
title: const Text('拍照'),
onTap: () => Navigator.pop(ctx, ImageSource.camera),
),
ListTile(
leading: const Icon(LucideIcons.images),
title: const Text('从相册选择'),
onTap: () => Navigator.pop(ctx, ImageSource.gallery),
),
],
),
),
);
if (source == null) return;
final xfile = await ImagePicker()
.pickImage(source: source, maxWidth: 2000, imageQuality: 85);
if (xfile == null) return;
await _uploadImage(filePath: xfile.path, fileName: xfile.name);
} else {
final result = await FilePicker.platform.pickFiles(
type: FileType.image, allowMultiple: false, withData: true);
if (result == null || result.files.isEmpty) return;
final file = result.files.first;
final String? filePath = kIsWeb ? null : file.path;
if (filePath == null && file.bytes == null) return;
await _uploadImage(
filePath: filePath, bytes: file.bytes, fileName: file.name);
}
}
Future<void> _uploadImage(
{String? filePath, Uint8List? bytes, required String fileName}) async {
setState(() => _uploading = true);
try {
await ref.read(productRepositoryProvider).uploadImage(
widget.productId, filePath,
bytes: bytes, fileName: fileName);
await _load();
} catch (e) {
if (mounted) {
showDsToast(context, '上传失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _uploading = false);
}
}
Future<void> _removeImage(ProductImage img) async {
try {
await ref
.read(productRepositoryProvider)
.deleteImage(widget.productId, img.id);
await _load();
} catch (e) {
if (mounted) {
showDsToast(context, '删除失败:$e', bg: context.tokens.danger);
}
}
}
// ── 保存:介绍正文 +(选了的话)介绍库文档 ──
/// 打印标签(同入库管理打印标签:QR + 品牌/型号/版本 + 门店信息)。
Future<void> _printLabel(Product p) async {
final qrBytes =
await ref.read(productRepositoryProvider).getQRCodeBytes(p.id);
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
if (!mounted) return;
final label = LabelData(
productId: p.id,
qrBytes: qrBytes,
name: p.name,
code: p.code,
series: (p.series ?? '').isEmpty ? null : p.series,
spec: (p.spec ?? '').isEmpty ? null : p.spec,
remark: (p.remark ?? '').isEmpty ? null : p.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
if (!mounted) return;
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [label]),
);
}
Future<void> _save() async {
setState(() => _saving = true);
try {
await ref.read(productRepositoryProvider).update(widget.productId, {
'description': _introCtrl.text,
if (_introDocId != null) 'description_doc_id': _introDocId,
});
if (!mounted) return;
// 抽屉内按钮不收抽屉(2026-07-04 拍板):保存后停留,可继续编辑
showDsToast(context, '商品介绍已保存 ✓');
} catch (e) {
if (mounted) {
showDsToast(context, '保存失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
String _fmtQty(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toString();
String _fmtMoney(double v) {
if (v >= 100000) return '¥${(v / 10000).toStringAsFixed(1)}';
final s =
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(2);
// 千分位
final parts = s.split('.');
final buf = StringBuffer();
final digits = parts[0];
for (var i = 0; i < digits.length; i++) {
if (i > 0 && (digits.length - i) % 3 == 0) buf.write(',');
buf.write(digits[i]);
}
return '¥$buf${parts.length > 1 ? '.${parts[1]}' : ''}';
}
}
/// 空图片槽虚线边框(.pe-tile border:1.5 dashed)。
class _DashedBorderPainter extends CustomPainter {
final Color color;
final double radius;
final double strokeWidth;
_DashedBorderPainter(
{required this.color, required this.radius, required this.strokeWidth});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
final rrect =
RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius));
final path = Path()..addRRect(rrect);
const dash = 5.0, gap = 4.0;
for (final metric in path.computeMetrics()) {
var d = 0.0;
while (d < metric.length) {
canvas.drawPath(
metric.extractPath(d, math.min(d + dash, metric.length)), paint);
d += dash + gap;
}
}
}
@override
bool shouldRepaint(_DashedBorderPainter old) =>
old.color != color || old.radius != radius;
}