feat(client): 标签打印预览弹窗(打印机选择 + 份数 + 所见即所得预览)

- 新增 LabelData 模型统一聚合单张标签字段(qrBytes/copies 等)
- 抽取 _renderLabelBitmap 共享位图绘制逻辑,TSPL 裸发与预览渲染完全一致
- 新增 renderLabelPreview / listLabelPrinters / detectDefaultPrinter 公开 API
- printProductLabel 新增 printerName 参数,透传到热敏裸发路径
- 新建 LabelPreviewDialog:缩略图条+大图主视图,打印机下拉+记忆(SharedPrefs)
  份数调整、勾选跳过、逐张打印进度状态显示
- 库存/商品详情/入库单 打标签 均接入预览弹窗,移除旧 _LabelPrintDialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-15 11:49:37 +08:00
parent 2d01e5d8cf
commit 104b98f4ac
9 changed files with 643 additions and 357 deletions
@@ -16,6 +16,8 @@ import '../../widgets/multi_select_dropdown.dart' show FilterableColumnHeader;
import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../core/utils/dialog_util.dart';
import '../../widgets/label_preview_dialog.dart';
import '../../providers/product_provider.dart';
import '../../providers/product_option_provider.dart';
import '../../providers/tab_state_provider.dart';
@@ -200,21 +202,23 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
await ref.read(productRepositoryProvider).getQRCodeBytes(item.productId!);
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
if (!context.mounted) return;
await safePrint(
context,
() => printProductLabel(
qrBytes: qrBytes,
name: item.productName,
code: item.productCode,
series: item.series.isEmpty ? null : item.series,
spec: item.spec.isEmpty ? null : item.spec,
batchNo: item.batchNo.isEmpty ? null : item.batchNo,
productionDate: item.productionDate,
remark: item.remark.isEmpty ? null : item.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
),
final label = LabelData(
productId: item.productId,
qrBytes: qrBytes,
name: item.productName,
code: item.productCode,
series: item.series.isEmpty ? null : item.series,
spec: item.spec.isEmpty ? null : item.spec,
batchNo: item.batchNo.isEmpty ? null : item.batchNo,
productionDate: item.productionDate,
remark: item.remark.isEmpty ? null : item.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [label]),
);
}
@@ -8,6 +8,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/config/app_config.dart';
import '../../core/utils/print_util.dart';
import '../../widgets/label_preview_dialog.dart';
import '../../core/theme/app_theme.dart';
import '../../models/product.dart';
import '../../models/product_image.dart';
@@ -656,8 +657,6 @@ class _QRCodeDialog extends ConsumerStatefulWidget {
class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
late Future<Uint8List> _future;
Uint8List? _bytes;
bool _printing = false;
String _printStatus = '';
@override
void initState() {
@@ -670,25 +669,25 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
});
}
Future<void> _print() async {
if (_bytes == null || _printing) return;
setState(() { _printing = true; _printStatus = '正在打印...'; });
void _print() {
if (_bytes == null) return;
final p = widget.product;
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
try {
await safePrint(context, () => printProductLabel(
qrBytes: _bytes!,
name: p.name,
code: p.code,
spec: p.spec,
series: p.series,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
));
} finally {
if (mounted) setState(() { _printing = false; _printStatus = ''; });
}
final label = LabelData(
productId: p.id,
qrBytes: _bytes,
name: p.name,
code: p.code,
spec: p.spec,
series: p.series,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [label]),
);
}
@override
@@ -717,15 +716,9 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
child: const Text('关闭'),
),
FilledButton.icon(
onPressed: _bytes == null ? null : (_printing ? null : _print),
icon: _printing
? const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.print, size: 16),
label: Text(_printing
? (_printStatus.isNotEmpty ? _printStatus : '准备中...')
: '打印'),
onPressed: _bytes == null ? null : _print,
icon: const Icon(Icons.print, size: 16),
label: const Text('打印'),
),
],
);
@@ -1,6 +1,7 @@
import '../../core/utils/dialog_util.dart';
import '../../core/errors/error_reporter.dart';
import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, printProductLabel;
import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, LabelData;
import '../../widgets/label_preview_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -15,7 +16,6 @@ import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButto
import '../../widgets/page_scaffold.dart';
import '../../widgets/status_badge.dart';
import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/tab_state_provider.dart';
import '../../providers/product_provider.dart' show productRepositoryProvider;
@@ -365,14 +365,25 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (!context.mounted) return;
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
await showDialog(
final labels = order.items
.map((item) => LabelData(
productId: item.productId,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
batchNo: item.batchNo,
productionDate: item.productionDate,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
))
.toList();
showAppDialog(
context: context,
builder: (_) => _LabelPrintDialog(
order: order,
productRepo: ref.read(productRepositoryProvider),
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
builder: (_) => LabelPreviewDialog(
labels: labels,
qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
),
);
},
@@ -996,149 +1007,3 @@ class _StatusFilterDropdown extends StatelessWidget {
}
}
class _LabelPrintDialog extends StatefulWidget {
final StockInOrder order;
final ProductRepository productRepo;
final String shopName;
final String shopAddress;
final String shopPhone;
const _LabelPrintDialog({
required this.order,
required this.productRepo,
this.shopName = '',
this.shopAddress = '',
this.shopPhone = '',
});
@override
State<_LabelPrintDialog> createState() => _LabelPrintDialogState();
}
class _LabelPrintDialogState extends State<_LabelPrintDialog> {
late final List<bool> _selected;
bool _printing = false;
String _status = '';
@override
void initState() {
super.initState();
_selected = List.filled(widget.order.items.length, true);
}
Future<void> _print() async {
setState(() { _printing = true; _status = '正在打印...'; });
int done = 0;
for (int i = 0; i < widget.order.items.length; i++) {
if (!_selected[i]) continue;
final item = widget.order.items[i];
try {
final qrBytes = await widget.productRepo.getQRCodeBytes(item.productId);
await printProductLabel(
qrBytes: qrBytes,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
batchNo: item.batchNo,
productionDate: item.productionDate,
shopName: widget.shopName,
shopAddress: widget.shopAddress,
shopPhone: widget.shopPhone,
);
done++;
if (mounted) setState(() => _status = '已打印 $done 张...');
} catch (e, st) {
reportError(e, st);
if (mounted) setState(() => _status = '${i + 1}行打印失败:$e');
}
}
if (mounted) setState(() { _printing = false; _status = '完成,共打印 $done'; });
}
@override
Widget build(BuildContext context) {
final items = widget.order.items;
return Dialog(
child: Container(
width: context.dialogWidth(520),
constraints: const BoxConstraints(maxHeight: 520),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: const BoxDecoration(
color: AppTheme.primary,
borderRadius: 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)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final item = items[i];
return CheckboxListTile(
value: _selected[i],
onChanged: _printing
? null
: (v) => setState(() => _selected[i] = v ?? false),
title: Text(
'${item.productCode ?? ''} ${item.productName ?? ''}',
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
'${item.productSeries ?? ''} ${item.productSpec ?? ''}',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
);
},
),
),
if (_status.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(_status,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: (_printing || !_selected.contains(true)) ? null : _print,
icon: const Icon(Icons.print_outlined, size: 16),
label: Text(_printing ? '打印中...' : '打印选中'),
),
],
),
),
],
),
),
);
}
}
@@ -1,7 +1,6 @@
import '../../repositories/product_repository.dart';
import '../../core/utils/dialog_util.dart';
import '../../core/errors/error_reporter.dart';
import '../../core/utils/print_util.dart' show safePrint, printStockOutOrder, printProductLabel;
import '../../core/utils/print_util.dart' show safePrint, printStockOutOrder;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -920,149 +919,3 @@ class _StatusFilterDropdown extends StatelessWidget {
}
}
class _LabelPrintDialog extends StatefulWidget {
final StockOutOrder order;
final ProductRepository productRepo;
final String shopName;
final String shopAddress;
final String shopPhone;
const _LabelPrintDialog({
required this.order,
required this.productRepo,
this.shopName = '',
this.shopAddress = '',
this.shopPhone = '',
});
@override
State<_LabelPrintDialog> createState() => _LabelPrintDialogState();
}
class _LabelPrintDialogState extends State<_LabelPrintDialog> {
late final List<bool> _selected;
bool _printing = false;
String _status = '';
@override
void initState() {
super.initState();
_selected = List.filled(widget.order.items.length, true);
}
Future<void> _print() async {
setState(() { _printing = true; _status = '正在打印...'; });
int done = 0;
for (int i = 0; i < widget.order.items.length; i++) {
if (!_selected[i]) continue;
final item = widget.order.items[i];
try {
final qrBytes = await widget.productRepo.getQRCodeBytes(item.productId);
await printProductLabel(
qrBytes: qrBytes,
name: item.productName ?? '',
code: item.productCode ?? '',
series: item.productSeries,
spec: item.productSpec,
shopName: widget.shopName,
shopAddress: widget.shopAddress,
shopPhone: widget.shopPhone,
);
done++;
if (mounted) setState(() => _status = '已打印 $done 张...');
} catch (e, st) {
reportError(e, st);
if (mounted) setState(() => _status = '${i + 1}行打印失败:$e');
}
}
if (mounted) {
setState(() { _printing = false; _status = '完成,共打印 $done'; });
}
}
@override
Widget build(BuildContext context) {
final items = widget.order.items;
return Dialog(
child: Container(
width: context.dialogWidth(520),
constraints: const BoxConstraints(maxHeight: 520),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: const BoxDecoration(
color: AppTheme.primary,
borderRadius: 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)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final item = items[i];
return CheckboxListTile(
value: _selected[i],
onChanged: _printing
? null
: (v) => setState(() => _selected[i] = v ?? false),
title: Text(
'${item.productCode ?? ''} ${item.productName ?? ''}',
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
'${item.productSeries ?? ''} ${item.productSpec ?? ''}',
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
);
},
),
),
if (_status.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(_status,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: (_printing || !_selected.contains(true)) ? null : _print,
icon: const Icon(Icons.print_outlined, size: 16),
label: Text(_printing ? '打印中...' : '打印选中'),
),
],
),
),
],
),
),
);
}
}