thermal_unicode_print 0.0.1
thermal_unicode_print: ^0.0.1 copied to clipboard
Prints any Unicode script (Bangla, Arabic, Devanagari, ...) on ESC/POS thermal printers by rasterizing text with dart:ui instead of screenshotting a Flutter widget tree. Renders one bounded line/row a [...]
import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
import 'package:thermal_unicode_print/thermal_unicode_print.dart';
// Bundled locally (see pubspec.yaml) rather than fetched at runtime like
// google_fonts does — a POS device at the counter has to be able to print
// a Bangla receipt with no internet connection.
const String _banglaFont = 'NotoSansBengali';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Pubspec-declared fonts load asynchronously in the background and don't
// block the first frame, so the very first receipt preview could
// otherwise render with a fallback font for one frame before the real one
// swaps in. Preloading here avoids that flash.
final fontBytes = await rootBundle.load(
'assets/fonts/NotoSansBengali-Variable.ttf',
);
await (FontLoader(_banglaFont)..addFont(Future.value(fontBytes))).load();
runApp(const DemoApp());
}
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'thermal_unicode_print demo',
theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
home: const ReceiptDemoPage(),
);
}
}
/// One printable unit of the demo receipt. Implementing both [preview] (PNG,
/// for the on-screen mock-up) and [escPos] (raster bytes for a real printer)
/// keeps the receipt content defined exactly once.
abstract class ReceiptStep {
Future<Uint8List> preview(ThermalUnicodeRenderer renderer);
Future<List<int>> escPos(ThermalUnicodeRenderer renderer, Generator gen);
}
class TextStep implements ReceiptStep {
final String text;
final TextAlign align;
final TextStyle style;
TextStep(
this.text, {
this.align = TextAlign.left,
TextStyle? style,
}) : style = style ?? const TextStyle(fontFamily: _banglaFont, fontSize: 28);
@override
Future<Uint8List> preview(ThermalUnicodeRenderer renderer) =>
renderer.textLinePng(text, align: align, style: style);
@override
Future<List<int>> escPos(ThermalUnicodeRenderer renderer, Generator gen) =>
renderer.textLine(gen, text, align: align, style: style);
}
class RowStep implements ReceiptStep {
final List<ThermalCell> cells;
final TextStyle style;
RowStep(this.cells, {TextStyle? style})
: style = style ?? const TextStyle(fontFamily: _banglaFont, fontSize: 22);
@override
Future<Uint8List> preview(ThermalUnicodeRenderer renderer) =>
renderer.rowPng(cells, style: style);
@override
Future<List<int>> escPos(ThermalUnicodeRenderer renderer, Generator gen) =>
renderer.row(gen, cells, style: style);
}
class DividerStep implements ReceiptStep {
@override
Future<Uint8List> preview(ThermalUnicodeRenderer renderer) =>
renderer.dividerPng();
@override
Future<List<int>> escPos(ThermalUnicodeRenderer renderer, Generator gen) =>
renderer.divider(gen);
}
/// Paper widths in dots at the typical 203dpi.
enum PaperWidth {
mm58(384, PaperSize.mm58),
mm80(576, PaperSize.mm80);
final int dots;
final PaperSize posSize;
const PaperWidth(this.dots, this.posSize);
}
class ReceiptDemoPage extends StatefulWidget {
const ReceiptDemoPage({super.key});
@override
State<ReceiptDemoPage> createState() => _ReceiptDemoPageState();
}
class _ReceiptDemoPageState extends State<ReceiptDemoPage> {
PaperWidth _paper = PaperWidth.mm58;
final TextEditingController _customTextController = TextEditingController(
text: 'আপনার নিজের বাংলা টেক্সট এখানে লিখুন',
);
List<BluetoothInfo> _devices = [];
bool _connecting = false;
bool _printing = false;
String? _status;
List<ReceiptStep> _buildDemoReceipt() {
return [
TextStep(
'ফ্রেশো শপ',
align: TextAlign.center,
style: const TextStyle(
fontFamily: _banglaFont,
fontSize: 34,
fontWeight: FontWeight.bold,
),
),
TextStep(
'ঢাকা, বাংলাদেশ',
align: TextAlign.center,
style: const TextStyle(fontFamily: _banglaFont, fontSize: 20),
),
DividerStep(),
RowStep([
ThermalCell(
'পণ্য',
flex: 5,
bold: true,
),
ThermalCell('দাম', flex: 3, align: TextAlign.center, bold: true),
ThermalCell('পরিমাণ', flex: 2, align: TextAlign.center, bold: true),
ThermalCell('মোট', flex: 3, align: TextAlign.right, bold: true),
]),
DividerStep(),
RowStep([
ThermalCell('চাল (মিনিকেট)', flex: 5),
ThermalCell('৬৫.০০', flex: 3, align: TextAlign.center),
ThermalCell('২', flex: 2, align: TextAlign.center),
ThermalCell('১৩০.০০', flex: 3, align: TextAlign.right),
]),
RowStep([
ThermalCell('সয়াবিন তেল', flex: 5),
ThermalCell('১৮৫.০০', flex: 3, align: TextAlign.center),
ThermalCell('১', flex: 2, align: TextAlign.center),
ThermalCell('১৮৫.০০', flex: 3, align: TextAlign.right),
]),
DividerStep(),
RowStep([
ThermalCell('সর্বমোট', flex: 8, bold: true),
ThermalCell('৩১৫.০০', flex: 5, align: TextAlign.right, bold: true),
]),
DividerStep(),
TextStep(
'ধন্যবাদ, আবার আসবেন',
align: TextAlign.center,
style: const TextStyle(fontFamily: _banglaFont, fontSize: 20),
),
];
}
// print_bluetooth_thermal never requests Android 12+ runtime Bluetooth
// permissions itself (its native permission-request code path is dead) —
// it just silently hangs if they aren't granted. So every entry point that
// touches the plugin has to secure permissions first.
Future<bool> _ensureBluetoothPermissions() async {
final statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
].request();
final granted = statuses.values.every((s) => s.isGranted);
if (!granted) {
setState(
() => _status =
'Bluetooth permission denied. Enable it in system settings to '
'find or connect to printers.',
);
}
return granted;
}
Future<void> _findPairedDevices() async {
if (!await _ensureBluetoothPermissions()) return;
setState(() => _status = 'Scanning paired devices…');
final devices = await PrintBluetoothThermal.pairedBluetooths;
setState(() {
_devices = devices;
_status = devices.isEmpty
? 'No paired Bluetooth printers found.'
: '${devices.length} paired device(s) found.';
});
}
Future<void> _connect(BluetoothInfo device) async {
if (!await _ensureBluetoothPermissions()) return;
setState(() => _connecting = true);
final ok = await PrintBluetoothThermal.connect(
macPrinterAddress: device.macAdress,
);
setState(() {
_connecting = false;
_status = ok
? 'Connected to ${device.name}.'
: 'Failed to connect to ${device.name}.';
});
}
Future<void> _printDemoReceipt() async {
final bool connected = await PrintBluetoothThermal.connectionStatus;
if (!connected) {
setState(() => _status = 'Connect to a printer first.');
return;
}
setState(() {
_printing = true;
_status = 'Printing…';
});
final renderer = ThermalUnicodeRenderer(dotsWidth: _paper.dots);
final profile = await CapabilityProfile.load();
final generator = Generator(_paper.posSize, profile);
// Each step is rasterized and sent one at a time — output size per step
// stays constant no matter how long the whole receipt is, unlike
// screenshotting the entire receipt as one widget.
final List<int> bytes = [];
for (final step in _buildDemoReceipt()) {
bytes.addAll(await step.escPos(renderer, generator));
}
bytes.addAll(generator.feed(1));
bytes.addAll(generator.cut());
await PrintBluetoothThermal.writeBytes(bytes);
setState(() {
_printing = false;
_status = 'Sent to printer.';
});
}
@override
Widget build(BuildContext context) {
final renderer = ThermalUnicodeRenderer(dotsWidth: _paper.dots);
return Scaffold(
appBar: AppBar(title: const Text('thermal_unicode_print demo')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
SegmentedButton<PaperWidth>(
segments: const [
ButtonSegment(value: PaperWidth.mm58, label: Text('58mm')),
ButtonSegment(value: PaperWidth.mm80, label: Text('80mm')),
],
selected: {_paper},
onSelectionChanged: (s) => setState(() => _paper = s.first),
),
const SizedBox(height: 16),
Text(
'Live preview — rendered with dart:ui, no widget on screen is '
'screenshotted to produce this.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
_ReceiptPreview(renderer: renderer, steps: _buildDemoReceipt()),
const SizedBox(height: 24),
Text('Try your own text', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
TextField(
controller: _customTextController,
maxLines: 2,
decoration: const InputDecoration(border: OutlineInputBorder()),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 8),
_PreviewImage(
future: renderer.textLinePng(
_customTextController.text,
style: const TextStyle(fontFamily: _banglaFont, fontSize: 26),
),
),
const SizedBox(height: 24),
Text(
'Print to a real printer',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton(
onPressed: _findPairedDevices,
child: const Text('Find paired devices'),
),
FilledButton(
onPressed: _printing ? null : _printDemoReceipt,
child: Text(_printing ? 'Printing…' : 'Print demo receipt'),
),
],
),
if (_devices.isNotEmpty)
..._devices.map(
(d) => ListTile(
title: Text(d.name),
subtitle: Text(d.macAdress),
trailing: _connecting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.bluetooth),
onTap: () => _connect(d),
),
),
if (_status != null) ...[
const SizedBox(height: 8),
Text(_status!, style: Theme.of(context).textTheme.bodyMedium),
],
],
),
);
}
}
class _ReceiptPreview extends StatelessWidget {
final ThermalUnicodeRenderer renderer;
final List<ReceiptStep> steps;
const _ReceiptPreview({required this.renderer, required this.steps});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.black26),
),
child: Column(
children: steps
.map((step) => _PreviewImage(future: step.preview(renderer)))
.toList(),
),
);
}
}
class _PreviewImage extends StatelessWidget {
final Future<Uint8List> future;
const _PreviewImage({required this.future});
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List>(
future: future,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox(
height: 24,
child: Center(
child: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
return Image.memory(snapshot.data!, gaplessPlayback: true);
},
);
}
}