Files
biz_xfj_cloud/lib/common/utils/manage/print_manage.dart
T
2026-09-22 09:35:58 +08:00

423 lines
14 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 'dart:ui' as ui;
import 'package:common_utils/common_utils.dart';
import 'package:drago_pos_printer/drago_pos_printer.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image/image.dart' as imglib;
import 'package:pj_supermarket_cloud/common/index.dart';
/// USB ESC/POS 打印机管理器,负责设备发现、连接、图片指令生成和完整字节写入。
class PrintManage extends BaseFacilityManage {
/// 打印机发现、连接和输出日志使用的稳定模块标签。
static const String _tag = "打印机管理";
/// 80mm 测试小票使用的标准光栅宽度,确保与正式凭证的输出宽度一致。
static const int _testPageWidth = 576;
/// 测试小票画布高度,完整容纳设备信息、灰度块和底部校验文字。
static const int _testPageHeight = 760;
/// 测试小票左右安全边距,避免真机可打印区域略窄时裁掉文字。
static const double _testPagePadding = 28;
/// 当前已选择并连接的 USB 打印管理器;未连接时为空。
USBPrinterManager? manager;
/// 当前 ESC/POS 能力配置,成功打开打印机后加载。
CapabilityProfile? profile;
/// ESC/POS 生成器使用的纸宽,首期固定为 80mm。
int paperSizeWidthMM = PaperSizeWidth.mm80;
/// 80mm 纸宽对应的文本列数,由图片模式之外的兼容命令使用。
int maxPerLine = PaperSizeMaxPerLine.mm80;
/// 最近一次发现、连接、下载或 USB 写入错误,供无人值守状态面板展示。
String? lastError;
/// 历史收银设备自动发现使用的已知 Vendor ID;后台未配置 VID/PID 时才允许回退。
static const List<String> _printVendorIds = [
"19267",
"4070",
"10473",
];
/// 返回设备状态体系中使用的打印机名称。
@override
String setName() => UsbFacilityNames.print.title;
/// 只发现 USB 设备,不建立打印连接,避免后台状态任务占用实际打印通道。
Future<bool> checkAvailable() async {
try {
final setting = await AppSettingTool.getSysSetting();
final printers = await USBPrinterManager.discover();
return _findTarget(printers, setting) != null;
} catch (e) {
lastError = e.toString();
logger.e("|$_tag|检查打印机失败error|$e|");
return false;
}
}
/// 枚举 Android 当前公开的标准 USB Printer Class 设备并转换为只读信息快照。
Future<List<PrinterUsbDeviceInfo>> discoverPrinterDevices() async {
final printers = await USBPrinterManager.discover();
return printers
.where(
(printer) => printer.vendorId != null && printer.productId != null)
.map(
(printer) => PrinterUsbDeviceInfo(
manufacturerName: printer.address,
productName: printer.name,
deviceId: printer.deviceId,
vendorId: printer.vendorId!,
productId: printer.productId!,
),
)
.toList(growable: false);
}
/// 优先按后台 VID/PID 精确匹配;未配置时才沿用已知厂商或唯一设备回退规则。
USBPrinter? _findTarget(List<USBPrinter> printers, SysSetting setting) {
final configuredVendor = setting.printerUsbVendorId;
final configuredProduct = setting.printerUsbProductId;
if (configuredVendor != null && configuredProduct != null) {
return printers.firstWhereOrNull((printer) =>
printer.vendorId == configuredVendor &&
printer.productId == configuredProduct);
}
USBPrinter? target = printers.firstWhereOrNull((printer) {
return _printVendorIds.contains(printer.vendorId?.toString() ?? "-1");
});
if (target == null && printers.length == 1) target = printers.first;
return target;
}
/// 打开目标 USB 打印机并装载 ESC/POS 能力;失败只更新设备状态,不改变订单结果。
@override
Future<bool> open() async {
final check = await super.open();
if (!check) {
AppDeviceStatusTool.updatePrinter(false);
return false;
}
try {
lastError = null;
final setting = await AppSettingTool.getSysSetting();
final target = _findTarget(await USBPrinterManager.discover(), setting);
if (target == null) {
lastError = setting.printerUsbVendorId == null
? '未发现可用 USB 打印机'
: '未发现后台配置 VID/PID 对应的 USB 打印机';
AppDeviceStatusTool.updatePrinter(false);
return false;
}
profile = await CapabilityProfile.load();
manager = USBPrinterManager(target);
await manager?.connect();
AppDeviceStatusTool.updatePrinter(true);
return true;
} catch (e) {
lastError = e.toString();
AppDeviceStatusTool.updatePrinter(false);
logger.e("|$_tag|打开打印机失败error|$e|");
return false;
}
}
/// 下载打印凭证图片,按 576px 等比缩放后生成 GS v 0 或 ESC * 字节并完整写入 USB。
///
/// 返回 true 只表示图片、走纸和可选切刀命令均被 USB 接口接受,不代表从硬件层确认出纸。
Future<bool> printImg(String img) async {
if (ObjectUtil.isEmpty(manager)) {
AppDeviceStatusTool.updatePrinterWorking(false);
return false;
}
try {
final imageBytes = await AppNetworkTool.downloadImage(img);
final image = imageBytes == null ? null : imglib.decodeImage(imageBytes);
if (image == null) {
lastError = '无法下载或解码打印图片';
return false;
}
final setting = await AppSettingTool.getSysSetting();
final normalized = image.width == 576
? image
: imglib.copyResize(
image,
width: 576,
interpolation: imglib.Interpolation.linear,
);
final generator = EscGenerator(
paperSizeWidthMM,
maxPerLine,
profile!,
);
final bytes = <int>[
...generator.feed(1),
...(setting.printerImageMode == 'ESC_STAR'
? generator.image(normalized)
: generator.imageRaster(normalized)),
...generator.feed(1),
if (setting.printerAutoCut) ...generator.cut(),
];
AppDeviceStatusTool.updatePrinterWorking(true);
await manager!.writeBytes(bytes);
lastError = null;
AppDeviceStatusTool.updatePrinter(true);
return true;
} catch (e) {
lastError = e.toString();
AppDeviceStatusTool.updatePrinter(false);
logger.e("|$_tag|打印机失败error|$e|");
return false;
} finally {
AppDeviceStatusTool.updatePrinterWorking(false);
}
}
/// 将测试文字和灰度校准块渲染成 576px PNG,避免依赖打印机中文代码页。
Future<imglib.Image> _buildTestPageImage(
PrinterUsbDeviceInfo device,
String printedAt,
String imageMode,
) async {
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
final backgroundPaint = ui.Paint()..color = Colors.white;
canvas.drawRect(
const Rect.fromLTWH(0, 0, _testPageWidth * 1.0, _testPageHeight * 1.0),
backgroundPaint,
);
var top = 26.0;
top = _drawTestPageText(
canvas,
'HQ 打印机测试',
top,
fontSize: 38,
fontWeight: FontWeight.w700,
textAlign: TextAlign.center,
);
top = _drawTestPageText(
canvas,
'打印内容显示正常',
top + 2,
fontSize: 24,
textAlign: TextAlign.center,
);
top = _drawTestPageDivider(canvas, top + 16);
top = _drawTestPageText(canvas, '打印时间:$printedAt', top + 15);
top = _drawTestPageText(canvas, '打印机:${device.displayName}', top + 4);
top = _drawTestPageText(
canvas,
'VID:${device.vendorId} / '
'0x${device.vendorId.toRadixString(16).padLeft(4, '0').toUpperCase()}',
top + 4,
);
top = _drawTestPageText(
canvas,
'PID:${device.productId} / '
'0x${device.productId.toRadixString(16).padLeft(4, '0').toUpperCase()}',
top + 4,
);
top = _drawTestPageText(canvas, '纸宽:80mm / 576px', top + 4);
top = _drawTestPageText(canvas, '图片模式:$imageMode', top + 4);
top = _drawTestPageDivider(canvas, top + 15);
top = _drawTestPageText(
canvas,
'黑度测试',
top + 15,
fontSize: 25,
fontWeight: FontWeight.w700,
);
const swatchGap = 12.0;
const swatchHeight = 62.0;
final swatchWidth =
(_testPageWidth - _testPagePadding * 2 - swatchGap * 3) / 4;
final swatchColors = <Color>[
const Color(0xFFE5E5E5),
const Color(0xFFAAAAAA),
const Color(0xFF555555),
Colors.black,
];
for (var index = 0; index < swatchColors.length; index++) {
final left = _testPagePadding + index * (swatchWidth + swatchGap);
canvas.drawRect(
Rect.fromLTWH(left, top + 8, swatchWidth, swatchHeight),
ui.Paint()..color = swatchColors[index],
);
}
top += swatchHeight + 24;
top = _drawTestPageText(
canvas,
'浅灰 中灰 深灰 纯黑',
top,
fontSize: 20,
textAlign: TextAlign.center,
);
top = _drawTestPageDivider(canvas, top + 15);
top = _drawTestPageText(
canvas,
'TEST PRINT COMPLETED',
top + 18,
fontSize: 24,
fontWeight: FontWeight.w700,
textAlign: TextAlign.center,
);
_drawTestPageText(
canvas,
'如能看到本页文字和色块,图片打印链路正常',
top + 4,
fontSize: 21,
textAlign: TextAlign.center,
);
final picture = recorder.endRecording();
ui.Image? renderedImage;
try {
renderedImage = await picture.toImage(_testPageWidth, _testPageHeight);
final pngData =
await renderedImage.toByteData(format: ui.ImageByteFormat.png);
if (pngData == null) throw StateError('无法编码测试打印图片');
final decoded = imglib.decodePng(pngData.buffer.asUint8List());
if (decoded == null) throw StateError('无法解码测试打印图片');
return decoded;
} finally {
renderedImage?.dispose();
picture.dispose();
}
}
/// 在测试小票画布上绘制一段可自动换行的黑色文字,并返回下一段起始纵坐标。
double _drawTestPageText(
ui.Canvas canvas,
String text,
double top, {
double fontSize = 23,
FontWeight fontWeight = FontWeight.w400,
TextAlign textAlign = TextAlign.left,
}) {
final painter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
color: Colors.black,
fontSize: fontSize,
fontWeight: fontWeight,
height: 1.25,
),
),
textAlign: textAlign,
textDirection: TextDirection.ltr,
)..layout(maxWidth: _testPageWidth - _testPagePadding * 2);
painter.paint(canvas, Offset(_testPagePadding, top));
final nextTop = top + painter.height;
painter.dispose();
return nextTop;
}
/// 绘制测试小票的水平分隔线,并返回分隔线下方的纵坐标。
double _drawTestPageDivider(ui.Canvas canvas, double top) {
canvas.drawLine(
Offset(_testPagePadding, top),
Offset(_testPageWidth - _testPagePadding, top),
ui.Paint()
..color = Colors.black
..strokeWidth = 2,
);
return top + 2;
}
/// 向指定 USB 打印机输出包含中英文、设备参数和灰度块的图片诊断小票。
///
/// 返回 true 只表示测试内容、走纸和可选切刀命令全部被 USB 接口接受,
/// 不代表已经从打印机硬件状态确认纸张实际输出。
Future<bool> printTestPage(PrinterUsbDeviceInfo device) async {
USBPrinterManager? testManager;
try {
lastError = null;
final setting = await AppSettingTool.getSysSetting();
final testProfile = await CapabilityProfile.load();
final generator = EscGenerator(
paperSizeWidthMM,
maxPerLine,
testProfile,
);
final printedAt = DateUtil.formatDate(
DateTime.now(),
format: DateFormats.full,
);
final imageMode =
setting.printerImageMode == 'ESC_STAR' ? 'ESC_STAR' : 'GS_V0';
final testPage = await _buildTestPageImage(
device,
printedAt,
imageMode,
);
final bytes = <int>[
...generator.feed(1),
...(imageMode == 'ESC_STAR'
? generator.image(testPage)
: generator.imageRaster(testPage)),
...generator.feed(2),
if (setting.printerAutoCut) ...generator.cut(),
];
testManager = USBPrinterManager(
USBPrinter(
name: device.productName,
address: device.manufacturerName,
deviceId: device.deviceId,
vendorId: device.vendorId,
productId: device.productId,
),
);
AppDeviceStatusTool.updatePrinterWorking(true);
await testManager.writeBytes(bytes);
AppDeviceStatusTool.updatePrinter(true);
return true;
} catch (e) {
lastError = e.toString();
AppDeviceStatusTool.updatePrinter(false);
logger.e('|$_tag|测试打印失败error|$e|');
return false;
} finally {
AppDeviceStatusTool.updatePrinterWorking(false);
if (testManager != null) {
try {
await testManager.disconnect();
} catch (_) {
// 写入流程已经给出准确结果,重复释放失败不覆盖本次测试打印结论。
}
}
}
}
/// 关闭当前 USB 连接并清除“打印中”状态;重复调用不报错。
@override
Future<void> close() async {
AppDeviceStatusTool.updatePrinterWorking(false);
if (ObjectUtil.isNotEmpty(manager)) {
if (manager != null) {
await manager?.disconnect();
}
manager = null;
}
}
/// USB 授权由项目内 Android 插件在连接时申请,此处无需前置 Flutter 权限。
@override
Future<bool> checkPermission() async {
return true;
}
/// USB 授权由项目内 Android 插件负责,保留基类接口并返回可继续连接。
@override
Future<bool> applyPermission() async {
return true;
}
}