Files
jiu/client/lib/widgets/label_preview_dialog.dart
T
wangjia 7b3a0ecdeb feat(client): 标签模板编辑器 P2 · 编辑器屏+存储+打印联动(原型同提交)
设备管理「价签·编辑」进入独立编辑器屏 /devices/label-template:
- 左实时预览走真实 renderLabelPreview 渲染路径(示例数据, debounce 重渲)
- 右属性面板:多模板管理(编辑对象/打印默认分离)、显示字段手风琴、
  高级区域位置、打印参数
- 存 shop.custom_fields.label_templates + label_template_active(零后端改)
- Option A:同物理行两字段(编号+型号 / 版本+日期)共用字号·加粗·对齐,
  各自独立开关;仅品名支持自动字号;字体不做(热敏单色)
- 打印联动:LabelPreviewDialog 改 ConsumerStatefulWidget, 未显式传模板时
  自动取本店 active 模板→所有打印入口(商品详情/出入库列表/编辑抽屉)一致生效

守护:
- 默认输出逐像素零变化由 label_render_golden_test 守(改内置默认常量才触发)
- 存储读写由 label_template_storage_test 单测守(纯内存, 不触真实库)
- 编辑器挂载由 label_template_editor_smoke_test 守
- 本屏不入自动 fidelity/golden 闸(左预览 Picture.toImage 需 runAsync,
  pumpAndSettle 骨架驱动不了), 原因+替代守法记入 CONTRACT 块4

design-first:原型 label-template-editor.html 去字体选择器、加 Option A 配对
镜像, 与真实屏同提交。真机热敏打印坐标/条码可扫须用户在打印机验收。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-30 09:56:23 +08:00

430 lines
16 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:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/errors/error_reporter.dart';
import '../core/theme/context_tokens.dart';
import '../core/responsive/responsive.dart';
import '../core/utils/print_util.dart';
import '../providers/shop_provider.dart';
import 'ds/ds_atoms.dart';
const _kPrinterKey = 'label_printer';
/// 标签打印预览弹窗:所见即所得预览(dart:ui 位图)+ 打印机选择 + 份数调整。
///
/// 单张模式(labels.length == 1):隐藏缩略图条,直接显示大图。
/// 多张模式:左侧竖排缩略图(带勾选),右侧大图主视图。
class LabelPreviewDialog extends ConsumerStatefulWidget {
final List<LabelData> labels;
/// 多张懒加载:按 productId 拉取 QR PNG 字节。单张场景可不传(qrBytes 已在 LabelData 中)。
final Future<Uint8List> Function(int productId)? qrFetcher;
/// 出签使用的版式模板;显式传入优先,否则自动取本店 active 模板
/// [activeLabelTemplate],缺失回退 [LabelTemplate.builtinDefault]),
/// 使店家自定义的价签版式在所有打印入口一致生效。
final LabelTemplate? template;
const LabelPreviewDialog({
super.key,
required this.labels,
this.qrFetcher,
this.template,
});
@override
ConsumerState<LabelPreviewDialog> createState() =>
_LabelPreviewDialogState();
}
class _LabelPreviewDialogState extends ConsumerState<LabelPreviewDialog> {
/// 有效模板:显式 template 优先,否则读本店 active 模板(异步未就绪时回退默认)。
LabelTemplate? get _effectiveTemplate {
if (widget.template != null) return widget.template;
final shop = ref.read(shopInfoProvider).valueOrNull;
return shop != null ? activeLabelTemplate(shop.customFields) : null;
}
int _selectedIndex = 0;
late List<Uint8List?> _previews;
late List<bool> _include;
List<String> _printers = [];
String? _printer;
bool _printing = false;
bool _printersChecked = false;
String _status = '';
@override
void initState() {
super.initState();
_previews =
List<Uint8List?>.filled(widget.labels.length, null, growable: false);
_include = List<bool>.filled(widget.labels.length, true, growable: false);
_init();
}
Future<void> _init() async {
// ── 枚举打印机,读取上次记忆 ──
final printers = await listLabelPrinters();
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_kPrinterKey);
String? selected;
if (saved != null && printers.contains(saved)) {
selected = saved;
} else {
selected = await detectDefaultPrinter() ??
(printers.isNotEmpty ? printers.first : null);
}
if (mounted) {
setState(() {
_printers = printers;
_printer = selected;
_printersChecked = true;
});
}
// ── 逐张渲染预览(progressive)──
for (int i = 0; i < widget.labels.length; i++) {
final label = widget.labels[i];
try {
// 懒加载 QR
if (label.qrBytes == null &&
label.productId != null &&
widget.qrFetcher != null) {
label.qrBytes = await widget.qrFetcher!(label.productId!);
}
if (label.qrBytes == null) continue;
final png = await renderLabelPreview(label, template: _effectiveTemplate);
if (mounted && png != null) {
setState(() => _previews[i] = png);
}
} catch (e, st) {
debugPrint('[preview] 渲染第${i + 1}张失败: $e');
reportError(e, st);
}
}
}
int get _totalPrintCount {
int total = 0;
for (int i = 0; i < widget.labels.length; i++) {
if (_include[i]) total += widget.labels[i].copies;
}
return total;
}
Future<void> _doPrint() async {
if (_printing) return;
setState(() {
_printing = true;
_status = '正在打印...';
});
int done = 0;
final total = _totalPrintCount;
for (int i = 0; i < widget.labels.length; i++) {
if (!_include[i]) continue;
final label = widget.labels[i];
for (int c = 0; c < label.copies; c++) {
try {
// 确保 QR 已拉取
if (label.qrBytes == null &&
label.productId != null &&
widget.qrFetcher != null) {
label.qrBytes = await widget.qrFetcher!(label.productId!);
}
if (label.qrBytes == null) continue;
await printProductLabel(
qrBytes: label.qrBytes!,
name: label.name,
code: label.code,
spec: label.spec,
series: label.series,
batchNo: label.batchNo,
productionDate: label.productionDate,
remark: label.remark,
shopName: label.shopName,
shopAddress: label.shopAddress,
shopPhone: label.shopPhone,
printerName: _printer,
template: _effectiveTemplate,
);
done++;
if (mounted) {
setState(() => _status = '已打印 $done/$total 张...');
}
} catch (e, st) {
reportError(e, st);
if (mounted) {
setState(() => _status = '${i + 1}张第${c + 1}份打印失败:$e');
}
}
}
}
if (mounted) {
setState(() {
_printing = false;
_status = '完成,共打印 $done';
});
Navigator.of(context).pop();
}
}
// ── 缩略图列表项 ─────────────────────────────────────────────────────────────
Widget _buildThumbnail(int i) {
final isSelected = _selectedIndex == i;
return GestureDetector(
onTap: () => setState(() => _selectedIndex = i),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
decoration: BoxDecoration(
border: Border.all(
color: isSelected ? context.tokens.primary : context.tokens.border,
width: isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(6),
color: isSelected
? context.tokens.primary.withOpacity(0.04)
: context.tokens.surface,
),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: Checkbox(
value: _include[i],
onChanged: _printing
? null
: (v) => setState(() => _include[i] = v ?? true),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
),
const SizedBox(width: 4),
SizedBox(
width: 76,
height: 38,
child: _previews[i] != null
? Image.memory(_previews[i]!, fit: BoxFit.contain)
: const Center(
child: SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2))),
),
const SizedBox(width: 6),
Text(
'${i + 1}',
style: TextStyle(
fontSize: 12,
color:
isSelected ? context.tokens.primary : context.tokens.muted,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
);
}
// ── 大图 + 份数控件 ──────────────────────────────────────────────────────────
Widget _buildMainPreview() {
final preview = _previews[_selectedIndex];
final label = widget.labels[_selectedIndex];
return Column(
children: [
Expanded(
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 230),
decoration: BoxDecoration(
border: Border.all(color: context.tokens.border),
borderRadius: BorderRadius.circular(4),
color: Colors.white, // ds-ignore: 打印纸面模拟固定色
),
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: preview != null
? Image.memory(preview, fit: BoxFit.contain)
: const Center(child: CircularProgressIndicator()),
),
),
),
),
const SizedBox(height: 8),
// 份数调节
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('打印份数:', style: TextStyle(fontSize: 13)),
IconButton(
icon: const Icon(LucideIcons.circleMinus, size: 20),
onPressed: (_printing || label.copies <= 1)
? null
: () => setState(() => label.copies--),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
SizedBox(
width: 32,
child: Text(
'${label.copies}',
textAlign: TextAlign.center,
style:
const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(LucideIcons.circlePlus, size: 20),
onPressed:
_printing ? null : () => setState(() => label.copies++),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
],
);
}
// ── build ────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final isMulti = widget.labels.length > 1;
final totalCount = _totalPrintCount;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Container(
width: context.dialogWidth(isMulti ? 640 : 440),
constraints: const BoxConstraints(maxHeight: 560),
child: Column(
children: [
// ── 顶栏 ──────────────────────────────────────────────────────────
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
color: context.tokens.primary,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
const Text('打印预览',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white)), // ds-ignore: 打印纸面模拟固定色
const Spacer(),
IconButton(
icon: const Icon(LucideIcons.x,
color: Colors.white), // ds-ignore: 打印纸面模拟固定色
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
// ── 打印机选择行 ──────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
const Text('打印机:', style: TextStyle(fontSize: 13)),
const SizedBox(width: 8),
Expanded(
child: _printers.isEmpty
? Text(_printersChecked ? '无打印机可用' : '正在检测打印机...',
style: TextStyle(
fontSize: 13, color: context.tokens.muted))
: DsSelect<String>(
value: _printer,
options: [for (final p in _printers) (p, p)],
onChanged: _printing
? null
: (v) async {
setState(() => _printer = v);
final prefs =
await SharedPreferences.getInstance();
await prefs.setString(_kPrinterKey, v);
},
),
),
],
),
),
const SizedBox(height: 8),
// ── 主体:缩略图条 + 大图 ─────────────────────────────────────────
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isMulti)
SizedBox(
width: 148,
child: ListView.builder(
itemCount: widget.labels.length,
itemBuilder: (_, i) => _buildThumbnail(i),
),
),
if (isMulti) const SizedBox(width: 8),
Expanded(child: _buildMainPreview()),
],
),
),
),
// ── 底栏 ─────────────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 14),
child: Row(
children: [
if (_status.isNotEmpty)
Expanded(
child: Text(
_status,
style: TextStyle(
fontSize: 12, color: context.tokens.muted),
),
)
else
const Spacer(),
TextButton(
onPressed:
_printing ? null : () => Navigator.of(context).pop(),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed:
(_printing || totalCount == 0 || _printer == null)
? null
: _doPrint,
child: Text(_printing ? '打印中...' : '打印 $totalCount'),
),
],
),
),
],
),
),
);
}
}