423 lines
14 KiB
Dart
423 lines
14 KiB
Dart
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;
|
||
}
|
||
}
|