unified_esc_pos_printer 1.0.2 copy "unified_esc_pos_printer: ^1.0.2" to clipboard
unified_esc_pos_printer: ^1.0.2 copied to clipboard

Unified ESC/POS thermal printer package for Flutter. Supports USB, Bluetooth Classic, BLE, and Network connections with a single PrinterManager API

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image/image.dart' as img;
import 'package:unified_esc_pos_printer/unified_esc_pos_printer.dart';

void main() => runApp(const PrinterDemoApp());

class PrinterDemoApp extends StatelessWidget {
  const PrinterDemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ESC/POS Printer Demo',
      theme: ThemeData(
        colorSchemeSeed: Colors.indigo,
        useMaterial3: true,
      ),
      home: const PrinterDemoPage(),
    );
  }
}

class PrinterDemoPage extends StatefulWidget {
  const PrinterDemoPage({super.key});

  @override
  State<PrinterDemoPage> createState() => _PrinterDemoPageState();
}

class _PrinterDemoPageState extends State<PrinterDemoPage> {
  final PrinterManager _manager = PrinterManager();
  final List<PrinterDevice> _devices = [];

  StreamSubscription<PrinterConnectionState>? _stateSub;
  StreamSubscription<List<PrinterDevice>>? _scanSub;

  PrinterConnectionState _state = PrinterConnectionState.disconnected;
  bool _scanning = false;

  /// Which connection types to scan. Null means scan all.
  PrinterConnectionType? _scanFilter;

  /// Which test parts to include when printing. All 6 by default.
  final Set<int> _selectedParts = {1, 2, 3, 4, 5, 6};

  static const _partLabels = {
    1: 'Image & Text',
    2: 'Row & Columns',
    3: 'Multilingual',
    4: 'Text Raster',
    5: 'Barcodes',
    6: 'QR, Beep & Cashdrawer',
  };

  @override
  void initState() {
    super.initState();
    _stateSub = _manager.stateStream.listen((s) {
      if (mounted) setState(() => _state = s);
    });
  }

  @override
  void dispose() {
    _stateSub?.cancel();
    _scanSub?.cancel();
    _manager.dispose();
    super.dispose();
  }

  Future<void> _startScan() async {
    final Set<PrinterConnectionType> types = _scanFilter != null
        ? {_scanFilter!}
        : PrinterConnectionType.values.toSet();

    setState(() {
      _scanning = true;
      _devices.clear();
    });

    _scanSub?.cancel();
    _scanSub = _manager
        .scanAll(timeout: const Duration(seconds: 5), types: types)
        .listen(
      (devices) {
        if (mounted) {
          setState(() {
            _devices.clear();
            _devices.addAll(devices);
          });
        }
      },
      onDone: () {
        if (mounted) setState(() => _scanning = false);
      },
      onError: (_) {
        if (mounted) setState(() => _scanning = false);
      },
    );
  }

  Future<void> _connectTo(PrinterDevice device) async {
    try {
      await _manager.connect(device);
      _showSnack('Connected to ${device.name}');
    } on PrinterException catch (e) {
      _showSnack('Connection failed: ${e.message}');
    }
  }

  Future<void> _disconnect() async {
    await _manager.disconnect();
    _showSnack('Disconnected');
  }

  Future<void> _printTestTicket() async {
    if (_state != PrinterConnectionState.connected) {
      _showSnack('No printer connected');
      return;
    }

    try {
      final ticket = await Ticket.create(PaperSize.mm80);

      if (_selectedParts.contains(1)) await _addPart1(ticket);
      if (_selectedParts.contains(2)) _addPart2(ticket);
      if (_selectedParts.contains(3)) await _addPart3(ticket);
      if (_selectedParts.contains(4)) await _addPart4(ticket);
      if (_selectedParts.contains(5)) _addPart5(ticket);
      if (_selectedParts.contains(6)) _addPart6(ticket);

      _addFooter(ticket);
      ticket.cut();

      await _manager.printTicket(ticket);
      _showSnack('Ticket printed!');
    } on PrinterException catch (e) {
      _showSnack('Print failed: ${e.message}');
    }
  }

  // PART 1: Image, Title, Text Styles, Sizes, Alignment, Fonts
  Future<void> _addPart1(Ticket ticket) async {
    final byteData = await rootBundle.load('assets/flutter_bnw.png');
    final banner = img.decodeImage(byteData.buffer.asUint8List())!;
    ticket.imageRaster(
      banner,
      align: PrintAlign.center,
      maxWidth: 400,
      maxHeight: 200,
    );

    ticket.emptyLines();

    ticket.text(
      'CAPABILITY DEMO',
      styles: const TextStyles(
        align: PrintAlign.center,
        bold: true,
        height: TextSize.size2,
        width: TextSize.size2,
      ),
    );
    ticket.text(
      'unified_esc_pos_printer',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );

    ticket.separator(char: '=');

    ticket.text(
      'TEXT STYLES',
      styles: const TextStyles(
        align: PrintAlign.center,
        bold: true,
        underline: true,
      ),
    );

    ticket.emptyLines();

    ticket.text('Normal text');
    ticket.text(
      'Bold text',
      styles: const TextStyles(bold: true),
    );
    ticket.text(
      'Underline text',
      styles: const TextStyles(underline: true),
    );
    ticket.text(
      'Reverse text',
      styles: const TextStyles(reverse: true),
    );
    ticket.text(
      'Bold + Underline',
      styles: const TextStyles(bold: true, underline: true),
    );

    ticket.emptyLines();

    ticket.text(
      'SIZE VARIATIONS',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.text(
      'Size 1 (default)',
      styles: const TextStyles(height: TextSize.size1, width: TextSize.size1),
    );
    ticket.text(
      'Size 2',
      styles: const TextStyles(height: TextSize.size2, width: TextSize.size2),
    );
    ticket.text(
      'Size 3',
      styles: const TextStyles(height: TextSize.size3, width: TextSize.size3),
    );
    ticket.text(
      'Tall only',
      styles: const TextStyles(height: TextSize.size3, width: TextSize.size1),
    );
    ticket.text(
      'Wide only',
      styles: const TextStyles(height: TextSize.size1, width: TextSize.size3),
    );

    ticket.emptyLines();

    ticket.text(
      'ALIGNMENT',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.text(
      'Left aligned',
      styles: const TextStyles(align: PrintAlign.left),
    );
    ticket.text(
      'Center aligned',
      styles: const TextStyles(align: PrintAlign.center),
    );
    ticket.text(
      'Right aligned',
      styles: const TextStyles(align: PrintAlign.right),
    );
    ticket.emptyLines();

    ticket.text(
      'FONT TYPES',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.text(
      'Font A (default)',
      styles: const TextStyles(fontType: FontType.fontA),
    );
    ticket.text(
      'Font B (smaller)',
      styles: const TextStyles(fontType: FontType.fontB),
    );

    ticket.emptyLines();
  }

  // PART 2: Separators, 2-col, 3-col, 4-col Tables
  void _addPart2(Ticket ticket) {
    ticket.text(
      'SEPARATORS',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.separator();
    ticket.separator(char: '=');
    ticket.separator(char: '*');
    ticket.separator(char: '~');

    ticket.emptyLines();

    ticket.text(
      '2-COLUMN TABLE',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.separator();
    ticket.row([
      PrintColumn(
        text: 'Item',
        flex: 2,
        styles: const TextStyles(bold: true),
      ),
      PrintColumn(
        text: 'Price',
        flex: 1,
        styles: const TextStyles(align: PrintAlign.right, bold: true),
      ),
    ]);
    ticket.separator();
    ticket.row([
      PrintColumn(
        text: 'Espresso',
        flex: 2,
      ),
      PrintColumn(
        text: '\$3.50',
        flex: 1,
        styles: const TextStyles(align: PrintAlign.right),
      ),
    ]);
    ticket.row([
      PrintColumn(
        text: 'Cappuccino',
        flex: 2,
      ),
      PrintColumn(
        text: '\$4.25',
        flex: 1,
        styles: const TextStyles(align: PrintAlign.right),
      ),
    ]);
    ticket.row([
      PrintColumn(
        text: 'Latte Macchiato',
        flex: 2,
      ),
      PrintColumn(
        text: '\$4.75',
        flex: 1,
        styles: const TextStyles(align: PrintAlign.right),
      ),
    ]);
    ticket.separator();
    ticket.row([
      PrintColumn(
        text: 'TOTAL',
        flex: 2,
        styles: const TextStyles(bold: true),
      ),
      PrintColumn(
        text: '\$12.50',
        flex: 1,
        styles: const TextStyles(align: PrintAlign.right, bold: true),
      ),
    ]);

    ticket.emptyLines();

    ticket.text(
      '3-COLUMN TABLE',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.separator();
    ticket.row([
      PrintColumn(
        text: 'Item',
        flex: 5,
        styles: const TextStyles(bold: true),
      ),
      PrintColumn(
        text: 'Qty',
        flex: 3,
        styles: const TextStyles(align: PrintAlign.center, bold: true),
      ),
      PrintColumn(
        text: 'Total',
        flex: 4,
        styles: const TextStyles(align: PrintAlign.right, bold: true),
      ),
    ]);
    ticket.separator();
    ticket.row([
      PrintColumn(
        text: 'Apple',
        flex: 5,
      ),
      PrintColumn(
        text: 'x3',
        flex: 3,
        styles: const TextStyles(align: PrintAlign.center),
      ),
      PrintColumn(
        text: '\$2.97',
        flex: 4,
        styles: const TextStyles(align: PrintAlign.right),
      ),
    ]);
    ticket.row([
      PrintColumn(
        text: 'Banana',
        flex: 5,
      ),
      PrintColumn(
        text: 'x6',
        flex: 3,
        styles: const TextStyles(align: PrintAlign.center),
      ),
      PrintColumn(
        text: '\$1.50',
        flex: 4,
        styles: const TextStyles(align: PrintAlign.right),
      ),
    ]);
    ticket.row([
      PrintColumn(
        text: 'Orange',
        flex: 5,
      ),
      PrintColumn(
        text: 'x2',
        flex: 3,
        styles: const TextStyles(align: PrintAlign.center),
      ),
      PrintColumn(
        text: '\$3.98',
        flex: 4,
        styles: const TextStyles(align: PrintAlign.right),
      ),
    ]);
    ticket.separator();
    ticket.row([
      PrintColumn(
        text: 'Grand Total',
        flex: 2,
        styles: const TextStyles(bold: true),
      ),
      PrintColumn(
        text: '\$8.45',
        flex: 1,
        styles: const TextStyles(align: PrintAlign.right, bold: true),
      ),
    ]);

    ticket.emptyLines();

    ticket.text(
      '4-COLUMN TABLE',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.separator();
    ticket.row([
      PrintColumn(
        text: '#',
        flex: 1,
        styles: const TextStyles(bold: true),
      ),
      PrintColumn(
        text: 'Name',
        flex: 5,
        styles: const TextStyles(bold: true),
      ),
      PrintColumn(
        text: 'Qty',
        flex: 2,
        styles: const TextStyles(align: PrintAlign.center, bold: true),
      ),
      PrintColumn(
        text: 'Price',
        flex: 4,
        styles: const TextStyles(align: PrintAlign.right, bold: true),
      ),
    ]);
    ticket.separator();
    ticket.row([
      PrintColumn(
        text: '1',
        flex: 1,
      ),
      PrintColumn(
        text: 'Widget',
        flex: 5,
      ),
      PrintColumn(
        text: '10',
        flex: 2,
        styles: const TextStyles(align: PrintAlign.center),
      ),
      PrintColumn(
        text: '\$99.90',
        flex: 4,
        styles: const TextStyles(align: PrintAlign.right),
      ),
    ]);
    ticket.row([
      PrintColumn(
        text: '2',
        flex: 1,
      ),
      PrintColumn(
        text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
        flex: 5,
      ),
      PrintColumn(
        text: '5',
        flex: 2,
        styles: const TextStyles(align: PrintAlign.center),
      ),
      PrintColumn(
        text: '\$74.95',
        flex: 4,
        styles: const TextStyles(align: PrintAlign.right),
      ),
    ]);
    ticket.row([
      PrintColumn(
        text: '3',
        flex: 1,
      ),
      PrintColumn(
        text: 'Fidget',
        flex: 5,
      ),
      PrintColumn(
        text: '5',
        flex: 2,
        styles: const TextStyles(align: PrintAlign.center),
      ),
      PrintColumn(
        text: '\$74.95',
        flex: 4,
        styles: const TextStyles(align: PrintAlign.right),
      ),
    ]);
    ticket.separator();

    ticket.emptyLines();
  }

  // PART 3: Multilingual Text
  Future<void> _addPart3(Ticket ticket) async {
    ticket.text(
      'MULTILINGUAL TEXT',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.separator();

    ticket.text('Chinese:', styles: const TextStyles(bold: true));
    await ticket.textRaster('欢迎光临,谢谢惠顾!');
    await ticket.textRaster('恭喜发财');

    ticket.emptyLines();

    ticket.text('Japanese:', styles: const TextStyles(bold: true));
    await ticket.textRaster('ようこそ、ありがとう!');
    await ticket.textRaster('東山奈央はこの世界で一番かわいい');

    ticket.emptyLines();

    ticket.text('Javanese:', styles: const TextStyles(bold: true));
    await ticket.textRaster('Sꦈꦒꦺꦤ꧀ꦒ꧀ ꦫwꦈꦲ꧀, ꦩꦠꦸꦂ ꦤꦸwꦈꦤ꧀!');
    await ticket.textRaster('ꦗwꦄ ꦄꦢꦭꦲ꧀ ꦏꦺꦴꦌꦤ꧀ꦠ꧀ꦗꦶ');

    ticket.emptyLines();

    ticket.text('Korean:', styles: const TextStyles(bold: true));
    await ticket.textRaster('환영합니다, 감사합니다!');
    await ticket.textRaster('최신 한국 드라마를 알려주세요');

    ticket.emptyLines();

    ticket.text('Arabic:', styles: const TextStyles(bold: true));
    await ticket.textRaster(
      'سلامت داتڠ، تريما کاسيه!',
      textDirection: TextDirection.rtl,
    );
    await ticket.textRaster(
      'ستي حلال براذر',
      textDirection: TextDirection.rtl,
    );

    ticket.emptyLines();

    ticket.text('Hindi:', styles: const TextStyles(bold: true));
    await ticket.textRaster('स्वागत है, धन्यवाद!');
    await ticket.textRaster('चला छैया छैया छैया');

    ticket.emptyLines();

    ticket.text('Thai:', styles: const TextStyles(bold: true));
    await ticket.textRaster('ยินดีต้อนรับ ขอบคุณ!');
    await ticket.textRaster('เช็กว่ายังออริและไม่แก้ไข');

    ticket.emptyLines();

    ticket.text('Russian:', styles: const TextStyles(bold: true));
    await ticket.textRaster('Добро пожаловать, спасибо!');
    await ticket.textRaster('За Родину Урааааа!');

    ticket.emptyLines();

    ticket.text('European:', styles: const TextStyles(bold: true));
    await ticket.textRaster('French: Bienvenue, merci à bientôt !');
    await ticket.textRaster('German: Willkommen, vielen Dank!');
    await ticket.textRaster('Spanish: ¡Bienvenidos, muchas gracias señor!');
    await ticket.textRaster('Portuguese: Bem-vindos, muito obrigado a você!');

    ticket.emptyLines();
  }

  // PART 4: Text Raster Styles
  Future<void> _addPart4(Ticket ticket) async {
    ticket.text(
      'TEXT RASTER STYLES',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.separator();

    ticket.text('Default (24 pt):', styles: const TextStyles(bold: true));
    await ticket.textRaster('欢迎光临 · Welcome · Bienvenido');

    ticket.emptyLines();

    ticket.text('Large (36 pt):', styles: const TextStyles(bold: true));
    await ticket.textRaster(
      '大きな文字 · 큰 글꼴',
      textStyle: const TextStyle(fontSize: 36),
    );

    ticket.emptyLines();

    ticket.text('Bold:', styles: const TextStyles(bold: true));
    await ticket.textRaster(
      'Bold — Gras — Negrita — 굵게',
      textStyle: const TextStyle(fontWeight: FontWeight.bold),
    );

    ticket.emptyLines();

    ticket.text('Italic:', styles: const TextStyles(bold: true));
    await ticket.textRaster(
      'Italic — Cursiva — 이탤릭',
      textStyle: const TextStyle(fontStyle: FontStyle.italic),
    );

    ticket.emptyLines();

    ticket.text('Bold + Italic:', styles: const TextStyles(bold: true));
    await ticket.textRaster(
      'Bold Italic — بولد إيطاليك',
      textStyle: const TextStyle(
        fontWeight: FontWeight.bold,
        fontStyle: FontStyle.italic,
      ),
    );

    ticket.emptyLines();

    ticket.text('Underline:', styles: const TextStyles(bold: true));
    await ticket.textRaster(
      'Underline · 밑줄 · 下線',
      textStyle: const TextStyle(decoration: TextDecoration.underline),
    );

    ticket.emptyLines();

    ticket.text('Small (18 pt):', styles: const TextStyles(bold: true));
    await ticket.textRaster(
      'Small text — Petit texte — 소문자',
      textStyle: const TextStyle(fontSize: 18),
    );

    ticket.emptyLines();

    ticket.text('Letter spacing:', styles: const TextStyles(bold: true));
    await ticket.textRaster(
      'S P A C E D',
      textStyle: const TextStyle(letterSpacing: 6),
    );

    ticket.emptyLines();

    ticket.text('Center aligned:', styles: const TextStyles(bold: true));
    await ticket.textRaster(
      '가운데 정렬 · Centré · 居中',
      align: PrintAlign.center,
    );

    ticket.emptyLines();

    ticket.text('Right aligned:', styles: const TextStyles(bold: true));
    await ticket.textRaster(
      'Right · Droite · 右揃え',
      align: PrintAlign.right,
    );

    ticket.emptyLines();

    ticket.text('RTL bold (Arabic):', styles: const TextStyles(bold: true));
    await ticket.textRaster(
      'مرحبا — نص عريض كبير',
      textStyle: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold),
      textDirection: TextDirection.rtl,
      align: PrintAlign.right,
    );

    ticket.emptyLines();
  }

  // PART 5: Barcodes
  void _addPart5(Ticket ticket) {
    ticket.text(
      'BARCODES',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.separator();

    ticket.text('EAN-13:', styles: const TextStyles(bold: true));
    ticket.barcode(
      '590123412345',
      type: BarcodeType.ean13,
      textPosition: BarcodeTextPosition.below,
    );

    ticket.emptyLines();

    ticket.text('EAN-8:', styles: const TextStyles(bold: true));
    ticket.barcode(
      '9031101',
      type: BarcodeType.ean8,
      textPosition: BarcodeTextPosition.below,
    );

    ticket.emptyLines();

    ticket.text('UPC-A:', styles: const TextStyles(bold: true));
    ticket.barcode(
      '01234567890',
      type: BarcodeType.upcA,
      textPosition: BarcodeTextPosition.below,
    );

    ticket.emptyLines();

    ticket.text('CODE 128:', styles: const TextStyles(bold: true));
    ticket.barcode(
      '{BABCDEF12345',
      type: BarcodeType.code128,
      textPosition: BarcodeTextPosition.below,
    );

    ticket.emptyLines();

    ticket.text('CODE 39:', styles: const TextStyles(bold: true));
    ticket.barcode(
      'HELLO123',
      type: BarcodeType.code39,
      textPosition: BarcodeTextPosition.below,
    );

    ticket.emptyLines();
  }

  // PART 6: QR Codes, Raster Images, Beep
  void _addPart6(Ticket ticket) {
    ticket.text(
      'QR CODES',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.separator();

    ticket.text('Small (size 3):', styles: const TextStyles(bold: true));
    ticket.qrcode('https://pub.dev', size: QRSize.size3);

    ticket.emptyLines();

    ticket.text('Medium (size 5):', styles: const TextStyles(bold: true));
    ticket.qrcode('https://pub.dev', size: QRSize.size5);

    ticket.emptyLines();

    ticket.text('Large (size 8):', styles: const TextStyles(bold: true));
    ticket.qrcode('https://pub.dev', size: QRSize.size8, cor: QRCorrection.H);

    ticket.emptyLines();

    ticket.text(
      'RASTER IMAGE',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.separator();

    final checker = img.Image(width: 160, height: 40);
    img.fill(checker, color: img.ColorRgb8(255, 255, 255));
    for (int y = 0; y < 40; y += 8) {
      for (int x = 0; x < 160; x += 8) {
        if ((x ~/ 8 + y ~/ 8) % 2 == 0) {
          img.fillRect(checker,
              x1: x,
              y1: y,
              x2: x + 7,
              y2: y + 7,
              color: img.ColorRgb8(0, 0, 0));
        }
      }
    }
    ticket.imageRaster(checker, align: PrintAlign.center);

    ticket.emptyLines();

    final gradient = img.Image(width: 200, height: 20);
    for (int x = 0; x < 200; x++) {
      final v = (x * 255 ~/ 199).clamp(0, 255);
      for (int y = 0; y < 20; y++) {
        gradient.setPixelRgb(x, y, v, v, v);
      }
    }
    ticket.text(
      'Gradient (dithered):',
      styles: const TextStyles(align: PrintAlign.center),
    );
    ticket.imageRaster(gradient, align: PrintAlign.center);

    ticket.emptyLines();

    ticket.text(
      'BEEP TEST',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.beep(n: 5, duration: BeepDuration.beep100ms);

    ticket.emptyLines();

    ticket.text(
      'OPEN CASH DRAWER TEST',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.openCashDrawer();

    ticket.emptyLines();
  }

  void _addFooter(Ticket ticket) {
    ticket.separator(char: '=');
    ticket.text(
      'unified_esc_pos_printer',
      styles: const TextStyles(align: PrintAlign.center, bold: true),
    );
    ticket.text(
      'Capabilities Demonstrated!',
      styles: const TextStyles(align: PrintAlign.center),
    );
    ticket.text(
      DateTime.now().toString().substring(0, 19),
      styles: const TextStyles(align: PrintAlign.center),
    );
    ticket.separator(char: '=');
  }

  void _showSnack(String msg) {
    if (!mounted) return;
    ScaffoldMessenger.of(context)
      ..clearSnackBars()
      ..showSnackBar(
        SnackBar(content: Text(msg), behavior: SnackBarBehavior.floating),
      );
  }

  IconData _iconFor(PrinterDevice device) {
    return switch (device) {
      NetworkPrinterDevice() => Icons.wifi,
      BlePrinterDevice() => Icons.bluetooth,
      BluetoothPrinterDevice() => Icons.bluetooth_audio,
      UsbPrinterDevice() => Icons.usb,
      _ => Icons.print,
    };
  }

  String _subtitleFor(PrinterDevice device) {
    return switch (device) {
      NetworkPrinterDevice(host: final h, port: final p) => 'TCP $h:$p',
      BlePrinterDevice(deviceId: final id) => 'BLE $id',
      BluetoothPrinterDevice(address: final addr) => 'BT $addr',
      UsbPrinterDevice(identifier: final id) => 'USB $id',
      _ => device.connectionType.name,
    };
  }

  String _filterLabel(PrinterConnectionType? filter) {
    if (filter == null) return 'All';
    return switch (filter) {
      PrinterConnectionType.network => 'Network',
      PrinterConnectionType.ble => 'BLE',
      PrinterConnectionType.bluetooth => 'Bluetooth',
      PrinterConnectionType.usb => 'USB',
    };
  }

  IconData _filterIcon(PrinterConnectionType? filter) {
    if (filter == null) return Icons.select_all;
    return switch (filter) {
      PrinterConnectionType.network => Icons.wifi,
      PrinterConnectionType.ble => Icons.bluetooth,
      PrinterConnectionType.bluetooth => Icons.bluetooth_audio,
      PrinterConnectionType.usb => Icons.usb,
    };
  }

  @override
  Widget build(BuildContext context) {
    final bool connected = _state == PrinterConnectionState.connected;
    final connectedDevice = _manager.connectedDevice;

    return Scaffold(
      appBar: AppBar(
        title: const Text('ESC/POS Printer Demo'),
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
            child: _ConnectionStatusBar(
              state: _state,
              device: connectedDevice,
              onDisconnect: connected ? _disconnect : null,
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
            child: SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              child: Row(
                children: [
                  _FilterChip(
                    label: 'All',
                    icon: Icons.select_all,
                    selected: _scanFilter == null,
                    onTap: () => setState(() => _scanFilter = null),
                  ),
                  for (final type in PrinterConnectionType.values)
                    _FilterChip(
                      label: _filterLabel(type),
                      icon: _filterIcon(type),
                      selected: _scanFilter == type,
                      onTap: () => setState(() => _scanFilter = type),
                    ),
                ],
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
            child: SizedBox(
              width: double.infinity,
              child: FilledButton.icon(
                onPressed: _scanning ? null : _startScan,
                icon: _scanning
                    ? const SizedBox.square(
                        dimension: 16,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : Icon(_filterIcon(_scanFilter)),
                label: Text(
                  _scanning
                      ? 'Scanning ${_filterLabel(_scanFilter)}...'
                      : 'Scan ${_filterLabel(_scanFilter)}',
                ),
              ),
            ),
          ),
          Expanded(
            child: _devices.isEmpty
                ? Center(
                    child: Text(
                      _scanning
                          ? 'Searching for printers...'
                          : 'No printers found. Tap Scan.',
                    ),
                  )
                : ListView.builder(
                    itemCount: _devices.length,
                    itemBuilder: (context, i) {
                      final device = _devices[i];
                      final bool isActive = _manager.connectedDevice == device;
                      return ListTile(
                        leading: Icon(_iconFor(device)),
                        title: Text(
                          device.name,
                          style: TextStyle(
                            fontWeight:
                                isActive ? FontWeight.bold : FontWeight.normal,
                          ),
                        ),
                        subtitle: Text(_subtitleFor(device)),
                        trailing:
                            isActive ? const Icon(Icons.check_circle) : null,
                        selected: isActive,
                        selectedTileColor: Colors.grey.withValues(alpha: 0.1),
                        onTap: connected || _scanning
                            ? null
                            : () => _connectTo(device),
                      );
                    },
                  ),
          ),
          Container(
            padding: EdgeInsets.all(12),
            decoration: BoxDecoration(
              color: Colors.white,
              boxShadow: [
                BoxShadow(
                  color: Colors.black.withValues(alpha: 0.02),
                  blurRadius: 3,
                  offset: const Offset(0, -2),
                )
              ],
            ),
            child: Column(
              children: [
                SingleChildScrollView(
                  scrollDirection: Axis.horizontal,
                  child: Row(
                    children: [
                      Padding(
                        padding: const EdgeInsets.only(right: 6),
                        child: FilterChip(
                          label: const Text('All'),
                          selected: _selectedParts.length == _partLabels.length,
                          onSelected: (_) => setState(() {
                            if (_selectedParts.length == _partLabels.length) {
                              _selectedParts.clear();
                            } else {
                              _selectedParts.addAll(_partLabels.keys);
                            }
                          }),
                        ),
                      ),
                      for (final entry in _partLabels.entries)
                        Padding(
                          padding: const EdgeInsets.only(right: 6),
                          child: FilterChip(
                            label: Text(entry.value),
                            selected: _selectedParts.contains(entry.key),
                            onSelected: (selected) => setState(() {
                              selected
                                  ? _selectedParts.add(entry.key)
                                  : _selectedParts.remove(entry.key);
                            }),
                          ),
                        ),
                    ],
                  ),
                ),
                SizedBox(height: 12),
                SizedBox(
                  width: double.infinity,
                  child: FilledButton.icon(
                    onPressed: _scanning || !connected || _selectedParts.isEmpty
                        ? null
                        : _printTestTicket,
                    icon: const Icon(Icons.receipt_long),
                    label: const Text('Print Test Ticket'),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class _ConnectionStatusBar extends StatelessWidget {
  const _ConnectionStatusBar({
    required this.state,
    required this.device,
    required this.onDisconnect,
  });

  final PrinterConnectionState state;
  final PrinterDevice? device;
  final VoidCallback? onDisconnect;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final bool connected = state == PrinterConnectionState.connected;
    final bool connecting = state == PrinterConnectionState.connecting;
    final bool error = state == PrinterConnectionState.error;

    final Color bgColor;
    final Color fgColor;
    final IconData icon;
    final String label;

    if (connected) {
      bgColor = Colors.green.shade50;
      fgColor = Colors.green.shade800;
      icon = Icons.check_circle;
      label = device?.name ?? 'Connected';
    } else if (connecting) {
      bgColor = Colors.orange.shade50;
      fgColor = Colors.orange.shade800;
      icon = Icons.sensors_rounded;
      label = 'Connecting...';
    } else if (error) {
      bgColor = Colors.red.shade50;
      fgColor = Colors.red.shade800;
      icon = Icons.error;
      label = 'Connection error';
    } else {
      bgColor =
          theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5);
      fgColor = theme.colorScheme.onSurfaceVariant;
      icon = Icons.print_disabled;
      label = 'No printer connected';
    }

    return Container(
      width: double.infinity,
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
      decoration: BoxDecoration(
        color: bgColor,
        borderRadius: BorderRadius.circular(8),
      ),
      child: Row(
        children: [
          Icon(icon, color: fgColor, size: 20),
          const SizedBox(width: 10),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  label,
                  style: theme.textTheme.bodyMedium?.copyWith(
                    color: fgColor,
                    fontWeight: FontWeight.w600,
                  ),
                ),
                if (connected && device != null)
                  Text(
                    '${device!.connectionType.name.toUpperCase()} - ${_deviceDetail(device!)}',
                    style: theme.textTheme.bodySmall?.copyWith(
                      color: fgColor.withValues(alpha: 0.7),
                    ),
                  ),
              ],
            ),
          ),
          if (connected)
            TextButton(
              onPressed: onDisconnect,
              child: Text('Disconnect', style: TextStyle(color: fgColor)),
            ),
        ],
      ),
    );
  }

  String _deviceDetail(PrinterDevice device) {
    return switch (device) {
      NetworkPrinterDevice(host: final h, port: final p) => '$h:$p',
      BlePrinterDevice(deviceId: final id) => id,
      BluetoothPrinterDevice(address: final addr) => addr,
      UsbPrinterDevice(identifier: final id) => id,
      _ => '',
    };
  }
}

class _FilterChip extends StatelessWidget {
  const _FilterChip({
    required this.label,
    required this.icon,
    required this.selected,
    required this.onTap,
  });

  final String label;
  final IconData icon;
  final bool selected;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(right: 6),
      child: FilterChip(
        showCheckmark: false,
        avatar: Icon(icon, size: 18),
        label: Text(label),
        selected: selected,
        onSelected: (_) => onTap(),
      ),
    );
  }
}
0
likes
0
points
324
downloads

Publisher

verified publisherelriztechnology.com

Weekly Downloads

Unified ESC/POS thermal printer package for Flutter. Supports USB, Bluetooth Classic, BLE, and Network connections with a single PrinterManager API

Repository (GitHub)
View/report issues

Topics

#esc-pos #esc-pos-printer #thermal-printer #receipt-printer #printer-manager

License

unknown (license)

Dependencies

flutter, flutter_libserialport, image, network_info_plus, usb_serial

More

Packages that depend on unified_esc_pos_printer

Packages that implement unified_esc_pos_printer