cpcl_esc_printer 0.2.0
cpcl_esc_printer: ^0.2.0 copied to clipboard
Flutter BLE thermal printer plugin with CPCL / ESC-POS / TSPL builders — text, barcode, QR, tables, images, plus optional Material scan/debug UI.
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:barcode/barcode.dart';
import 'package:cpcl_esc_printer/cpcl_esc_printer.dart';
import 'package:fast_gbk/fast_gbk.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() => runApp(const ExampleApp());
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'cpcl_esc_printer example',
theme: ThemeData(useMaterial3: true),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
// ---- Example-only persistence keys (plugin does NOT do this itself) ----
static const _kLastDeviceId = 'last_printer_id';
static const _kLastDeviceName = 'last_printer_name';
final _manager = PrinterManager.instance;
StreamSubscription<PrinterConnectionState>? _stateSub;
PrinterConnectionState _state = PrinterConnectionState.disconnected;
String? _lastId;
String? _lastName;
// Encoding toggle: GBK for Chinese thermal printers, UTF-8 otherwise.
bool _useGbk = true;
TextEncoder get _encoder => _useGbk ? gbk.encode : utf8.encode;
@override
void initState() {
super.initState();
_stateSub = _manager.stateStream.listen((s) {
if (mounted) setState(() => _state = s);
// Persist the device once it's ready (example-only).
if (s == PrinterConnectionState.ready) _saveLastDevice();
});
_loadLastDevice();
}
@override
void dispose() {
_stateSub?.cancel();
super.dispose();
}
// ---- Permissions (example-only, via permission_handler) ----
Future<bool> _ensurePermissions() async {
final statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.locationWhenInUse, // only needed for scanning on Android < 12
].request();
bool granted(Permission p) {
final s = statuses[p];
return s != null && (s.isGranted || s.isLimited);
}
// BLUETOOTH_SCAN/CONNECT are the load-bearing permissions on Android 12+
// (scan is declared neverForLocation). Location is only required for
// scanning on Android < 12, so it must not block the gate on newer devices.
final ok =
granted(Permission.bluetoothScan) && granted(Permission.bluetoothConnect);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Bluetooth permissions are required.')),
);
}
return ok;
}
// ---- Persistence (example-only, via shared_preferences) ----
Future<void> _loadLastDevice() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_lastId = prefs.getString(_kLastDeviceId);
_lastName = prefs.getString(_kLastDeviceName);
});
}
Future<void> _saveLastDevice() async {
final id = _manager.connectedDeviceId;
if (id == null) return;
final name = _manager.connectedDeviceName ?? '';
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kLastDeviceId, id);
await prefs.setString(_kLastDeviceName, name);
setState(() {
_lastId = id;
_lastName = name;
});
}
Future<void> _scan() async {
if (!await _ensurePermissions()) return;
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PrinterScannerPage(encoder: _encoder),
),
);
}
Future<void> _reconnectLast() async {
if (_lastId == null) return;
if (!await _ensurePermissions()) return;
try {
await _manager.connect(
BleDevice(id: _lastId!, name: _lastName ?? '', rssi: 0),
profile: PrinterProfile.autoDetect,
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Reconnect failed: $e')));
}
}
}
// Rasterize a barcode/QR (pure-Dart `barcode` geometry) into a MonoBitmap so
// it can be printed via EscBuilder.image() — works on printers that don't
// render ESC/POS barcode/QR commands (they print raster fine).
MonoBitmap _codeBitmap(Barcode bc, String data,
{required int width, required int height}) {
final widthBytes = (width + 7) >> 3;
final out = Uint8List(widthBytes * height);
for (final e in bc.make(data,
width: width.toDouble(), height: height.toDouble(), drawText: false)) {
if (e is BarcodeBar && e.black) {
final x0 = e.left.floor().clamp(0, width);
final x1 = (e.left + e.width).ceil().clamp(0, width);
final y0 = e.top.floor().clamp(0, height);
final y1 = (e.top + e.height).ceil().clamp(0, height);
for (var y = y0; y < y1; y++) {
for (var x = x0; x < x1; x++) {
out[y * widthBytes + (x >> 3)] |= 0x80 >> (x & 7);
}
}
}
}
return MonoBitmap(width: width, height: height, bytes: out);
}
// Full ESC/POS receipt with a barcode and QR rendered as raster images.
Future<void> _printReceipt() async {
if (!_manager.isReady) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Connect first.')));
return;
}
const orderNo = '20240717001';
const w = 48;
final divider = '-' * w;
final barcodeBmp =
_codeBitmap(Barcode.code128(), orderNo, width: 360, height: 80);
final qrBmp = _codeBitmap(
Barcode.qrCode(), 'https://m.example.com/r/$orderNo',
width: 180, height: 180);
final bytes = EscBuilder(encoder: _encoder)
.align(EscAlign.center)
.fontSize(17)
.text('阳光超市')
.fontSize(0)
.printMode(0)
.text('欢迎光临')
.text('电话 000-00000000')
.align(EscAlign.left)
.text(divider)
.text('单号 $orderNo')
.text('时间 2024-07-17 12:30')
.text('收银 001 张三')
.text(divider)
.table(
[
['商品', '数量', '金额'],
['可乐', '2', '6.00'],
['面包', '1', '4.50'],
['牛奶', '3', '15.00'],
],
columnWidths: [24, 8, 16],
align: [EscAlign.left, EscAlign.center, EscAlign.right],
rule: true,
)
.text(divider)
.twoColumns('合计', '25.50', leftWidth: w - 5)
.twoColumns('实付(微信)', '25.50', leftWidth: w - 5)
.text(divider)
.align(EscAlign.center)
.image(barcodeBmp)
.feed()
.image(qrBmp)
.feed()
.text('谢谢惠顾 欢迎再次光临')
.feed(lines: 3)
.build();
await _manager.writeRaw(bytes);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('cpcl_esc_printer example')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('State: ${_state.name}'),
const SizedBox(height: 4),
Text('Last printer: ${_lastName ?? '-'} ${_lastId ?? ''}'),
],
),
),
),
const SizedBox(height: 16),
Row(
children: [
const Text('Encoding: '),
const SizedBox(width: 8),
Expanded(
child: SegmentedButton<bool>(
segments: const [
ButtonSegment(value: true, label: Text('GBK (中文)')),
ButtonSegment(value: false, label: Text('UTF-8')),
],
selected: {_useGbk},
onSelectionChanged: (s) => setState(() => _useGbk = s.first),
),
),
],
),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.bluetooth_searching),
label: const Text('Scan printers'),
onPressed: _scan,
),
const SizedBox(height: 8),
OutlinedButton.icon(
icon: const Icon(Icons.autorenew),
label: Text(_lastId == null
? 'No saved printer'
: 'Reconnect last (${_lastName ?? _lastId})'),
onPressed: _lastId == null ? null : _reconnectLast,
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () async {
if (!_manager.isReady) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Connect first.')),
);
return;
}
final bytes = CpclBuilder(width: 400, height: 200, encoder: _encoder)
.text('Hello 你好', 20, 20, size: 2)
.qrcode('https://pub.flutter-io.cn', 20, 60, u: 5)
.build();
await _manager.writeRaw(bytes);
},
child: const Text('Quick CPCL print'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: _printReceipt,
child: const Text('小票打印(条码+二维码图形)'),
),
const Spacer(),
const Text(
'Persistence & permissions live in the EXAMPLE only.\n'
'The plugin itself depends on flutter_blue_plus alone.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
);
}
}