Fifty Printing Engine

pub package License: MIT

Manage a fleet of thermal printers with automatic routing, reconnection, and paper size conversion.

A production-grade ESC/POS printing engine -- register Bluetooth and WiFi printers, route jobs by role or user selection, auto-reconnect silently, and convert tickets across paper sizes -- all through a single engine.print() call. Part of Fifty Flutter Kit.

Home Printer Management Test Print Ticket Builder

Why fifty_printing_engine

  • Manage a whole printer fleet -- Register Bluetooth and WiFi printers, configure routing strategies (print-to-all, role-based, select-per-print), and fire print jobs without managing connections manually.
  • Auto-reconnects silently -- Disconnected printers are reconnected automatically during print; your code just calls engine.print() and inspects the PrintResult.
  • Role-based routing for kitchens -- Assign printers a role (kitchen/receipt/both) and target print jobs by role; the routing strategy handles which printers fire.
  • Paper size conversion built in -- Provide a regenerator callback and the engine regenerates the ticket for each printer's paper size automatically.

Installation

dependencies:
  fifty_printing_engine: ^1.0.3

For Contributors

dependencies:
  fifty_printing_engine:
    path: ../fifty_printing_engine

Dependencies: escpos, print_bluetooth_thermal, permission_handler


Quick Start

import 'package:fifty_printing_engine/fifty_printing_engine.dart';
import 'package:escpos/escpos.dart';

void main() async {
  final engine = PrintingEngine.instance;
  final profile = await CapabilityProfile.load();

  // 1. Register a printer
  engine.registerPrinter(WiFiPrinterDevice(
    id: 'printer-1',
    name: 'Kitchen Printer',
    ipAddress: '192.168.1.100',
    port: 9100,
  ));

  // 2. Create a ticket
  final ticket = PrintTicket(PaperSize.mm80, profile);
  ticket.text('Hello World!', styles: PosStyles(bold: true));
  ticket.feed(2);
  ticket.cut();

  // 3. Print
  final result = await engine.print(ticket: ticket);

  if (result.isSuccess) {
    print('Printed successfully!');
  } else {
    print('Print failed');
  }
}

Architecture

PrintingEngine (Singleton)
    |
    +-- PrinterDevice (Abstract)
    |       +-- BluetoothPrinterDevice
    |       +-- WiFiPrinterDevice
    |
    +-- PrintingStrategy (Abstract)
    |       +-- PrintToAllStrategy
    |       +-- RoleBasedRoutingStrategy
    |       +-- SelectPerPrintStrategy
    |
    +-- PrintTicket
    |       ESC/POS ticket with paper size tracking
    |
    +-- Health Monitor
            Periodic and manual health checks

Core Components

Component Description
PrintingEngine Singleton orchestrator for all printer operations
PrinterDevice Abstract base for Bluetooth and WiFi printer implementations
PrintTicket ESC/POS ticket wrapper with paper size tracking
PrintingStrategy Abstract routing strategy (print-to-all, role-based, select-per-print)
PrintResult Aggregated result with per-printer success/failure details

Customization

Routing Strategies

The engine ships with three strategies for deciding which printers receive a job. Switch between them at any time.

Print to all -- every registered printer receives every ticket:

engine.setPrintingMode(PrintingMode.printToAll);
await engine.print(ticket: ticket);

Role-based routing -- target printers by role (kitchen, receipt, both):

engine.setPrintingMode(PrintingMode.roleBasedRouting);

await engine.print(
  ticket: kitchenTicket,
  targetRole: PrinterRole.kitchen,
);

Select per print -- prompt the operator to choose printers for each job:

engine.setPrintingMode(PrintingMode.selectPerPrint);

engine.setPrinterSelectionCallback((printers, suggestedRole) async {
  final selected = await showPrinterSelectionDialog(
    printers: printers,
    preselectedRole: suggestedRole,
  );
  return selected?.map((p) => p.id).toList();
});

await engine.print(
  ticket: ticket,
  targetRole: PrinterRole.kitchen, // Hint for dialog pre-selection
);

Paper Size Regenerator

When a ticket's paper size differs from a printer's paper size, provide a regenerator callback. The engine calls it once per mismatched printer and prints the regenerated ticket.

await engine.print(
  ticket: ticket, // Created for mm80
  regenerator: (paperSize) async {
    // Regenerate ticket layout for the printer's paper width
    return generateTicket(order, paperSize);
  },
);

Copy Control

Set default copies per printer at registration time, then override per job:

// Kitchen always prints 2 copies by default
final printer = BluetoothPrinterDevice(
  id: 'kitchen-1',
  name: 'Kitchen',
  macAddress: '00:11:22:33:44:55',
  defaultCopies: 2,
);

// Override: print 1 copy for this job only
await engine.print(ticket: ticket, copies: 1);

Persistence

The engine is in-memory only. Export and import configuration with your preferred storage:

// Save (on app pause/exit)
final config = engine.exportConfiguration();
await storage.save('printer_config', jsonEncode(config));

// Restore (on app start)
final json = await storage.load('printer_config');
if (json != null) engine.importConfiguration(jsonDecode(json));

API Reference

PrintingEngine

The main orchestrator class (singleton). Manages all printer operations including registration, routing, printing, and health monitoring.

final engine = PrintingEngine.instance;

// Register printers
engine.registerPrinter(myPrinter);

// Configure routing
engine.setPrintingMode(PrintingMode.roleBasedRouting);

// Print
final result = await engine.print(ticket: ticket);

Key Responsibilities:

  • Printer registration and lifecycle management
  • Routing strategy execution
  • Bluetooth discovery and permissions
  • Configuration export/import
  • Health check scheduling
Method Description
instance Singleton instance
registerPrinter(device) Register a printer
updatePrinter(id, device) Update printer config (preserves connection)
removePrinter(id) Remove a printer
clear() Remove all printers
reset() Reset all configs to defaults
getAvailablePrinters({filterByStatus}) Get all/filtered printers
getPrintersByRole(role) Get printers by role
setPrintingMode(mode) Set routing mode
setRoleMapping(role, ids) Configure role mapping
getRoleMapping(role) Get role mapping
setPrinterSelectionCallback(callback) Set SelectPerPrint callback
print({ticket, copies, targetRole, targetPrinterIds, regenerator}) Print a ticket
scanBluetoothPrinters({filterPrintersOnly}) Discover Bluetooth printers
requestBluetoothPermissions() Check/request permissions
isBluetoothEnabled() Check Bluetooth enabled
hasBluetoothPermissions() Check permissions granted
openBluetoothSettings() Open app settings
enableHealthChecks({interval}) Enable periodic health checks
disableHealthChecks() Disable health checks
checkPrinterHealth(id) Manual health check
checkAllPrinters() Check all printers
exportConfiguration() Export config as JSON
importConfiguration(config) Import config from JSON
dispose() Clean up resources
Property Description
statusStream Stream of printer status events
printingMode Current routing mode
roleMappings Current role mappings
selectionCallback Registered selection callback

PrinterDevice

Abstract base class for printer implementations. Manages connection lifecycle, status tracking, and print execution with automatic reconnection.

Implementations:

  • BluetoothPrinterDevice - Bluetooth thermal printers via MAC address
  • WiFiPrinterDevice - Network ESC/POS printers via IP address and port
// Bluetooth printer
final btPrinter = BluetoothPrinterDevice(
  id: 'bt-kitchen',
  name: 'Kitchen Printer',
  macAddress: '00:11:22:33:44:55',
  role: PrinterRole.kitchen,
  paperSize: PaperSize.mm80,
  defaultCopies: 2,
);

// WiFi printer
final wifiPrinter = WiFiPrinterDevice(
  id: 'wifi-receipt',
  name: 'Receipt Printer',
  ipAddress: '192.168.1.100',
  port: 9100,
  role: PrinterRole.receipt,
  paperSize: PaperSize.mm80,
  defaultCopies: 1,
);

Properties:

Property Type Description
id String Unique identifier
name String Human-readable name
type PrinterType bluetooth or wifi
role PrinterRole? kitchen, receipt, or both
status PrinterStatus Current connection status
paperSize PaperSize mm58 or mm80
defaultCopies int Default copies per print job
metadata Map? App-specific settings

PrintTicket

Wrapper around escpos.Ticket that adds paper size tracking for automatic conversion. Use standard escpos API methods.

final profile = await CapabilityProfile.load();
final ticket = PrintTicket(PaperSize.mm80, profile);

ticket.text('ORDER #123', styles: PosStyles(
  bold: true,
  height: PosTextSize.size2,
  align: PosAlign.center,
));
ticket.hr();
ticket.row([
  PosColumn(text: 'Item', width: 8),
  PosColumn(text: 'Qty', width: 4),
]);
ticket.row([
  PosColumn(text: 'Burger', width: 8),
  PosColumn(text: 'x2', width: 4),
]);
ticket.feed(2);
ticket.cut();

Available Methods (from escpos):

  • text() - Print text with styles
  • row() - Print columnar data
  • hr() - Horizontal rule
  • feed() - Line feeds
  • cut() - Cut paper
  • qrcode() - QR codes
  • barcode() - Barcodes
  • image() - Images

Printing Strategies

Abstract base class for routing strategies. Determines which printers receive a print job and aggregates results.

PrintToAllStrategy

Sends print jobs to all registered printers. Ignores role hints.

engine.setPrintingMode(PrintingMode.printToAll);

// Prints to ALL registered printers
await engine.print(ticket: ticket);

Use Case: Small setups where every printer should receive every ticket.

RoleBasedRoutingStrategy

Routes print jobs based on printer roles. Printers with PrinterRole.both receive all role-targeted jobs.

engine.setPrintingMode(PrintingMode.roleBasedRouting);

// Route to kitchen printers only
await engine.print(
  ticket: kitchenTicket,
  targetRole: PrinterRole.kitchen,
);

// Route to receipt printers only
await engine.print(
  ticket: receiptTicket,
  targetRole: PrinterRole.receipt,
);

Use Case: Restaurant/retail with dedicated kitchen and receipt printers.

SelectPerPrintStrategy

Prompts user to select printers for each print job via callback. Requires registering a selection callback.

engine.setPrintingMode(PrintingMode.selectPerPrint);

// Register selection callback (shows UI dialog)
engine.setPrinterSelectionCallback((printers, suggestedRole) async {
  // Show your selection dialog
  final selected = await showPrinterSelectionDialog(
    printers: printers,
    preselectedRole: suggestedRole,
  );
  return selected?.map((p) => p.id).toList();
});

// Print - callback is invoked for user selection
await engine.print(
  ticket: ticket,
  targetRole: PrinterRole.kitchen, // Hint for dialog pre-selection
);

Use Case: Flexible setups where operators choose destination per job.

Printer Management

Registering Printers

// Register Bluetooth printer
engine.registerPrinter(BluetoothPrinterDevice(
  id: 'bt-1',
  name: 'Kitchen Bluetooth',
  macAddress: '00:11:22:33:44:55',
  role: PrinterRole.kitchen,
  defaultCopies: 2,
));

// Register WiFi printer
engine.registerPrinter(WiFiPrinterDevice(
  id: 'wifi-1',
  name: 'Receipt WiFi',
  ipAddress: '192.168.1.100',
  port: 9100,
  role: PrinterRole.receipt,
));

// Get all printers
final printers = engine.getAvailablePrinters();

// Get printers by status
final connected = engine.getAvailablePrinters(
  filterByStatus: PrinterStatus.connected,
);

// Get printers by role
final kitchenPrinters = engine.getPrintersByRole(PrinterRole.kitchen);

// Remove printer
engine.removePrinter('bt-1');

// Clear all printers
engine.clear();

Bluetooth Discovery

try {
  // Scan for Bluetooth printers (handles permissions automatically)
  final discovered = await engine.scanBluetoothPrinters(
    filterPrintersOnly: true, // Filter to printer-like devices
  );

  for (final printer in discovered) {
    print('Found: ${printer.name} - ${printer.macAddress}');
  }

  // Register discovered printer
  if (discovered.isNotEmpty) {
    final device = discovered.first.toDevice(
      id: 'printer-1',
      role: PrinterRole.kitchen,
    );
    engine.registerPrinter(device);
  }
} catch (e) {
  // Permission error - direct user to settings
  if (e.toString().contains('permission') || e.toString().contains('Settings')) {
    await engine.openBluetoothSettings();
  }
  print('Error: $e');
}

Permission Handling:

  • Android 12+: Requires Bluetooth and Nearby Devices permissions. Package checks but cannot request (Android limitation). Throws helpful error directing to App Settings.
  • iOS: Permission dialog shown automatically when scanning. Uses NSBluetoothAlwaysUsageDescription from Info.plist.

Connecting and Disconnecting

Printers auto-connect when printing. Manual control available:

// Manual connect
final success = await printer.connect();

// Manual disconnect
await printer.disconnect();

// Check status
if (printer.status == PrinterStatus.connected) {
  print('Printer is ready');
}

Printer Status Values:

Status Description
disconnected Not connected
connecting Connection in progress
connected Ready to print
printing Print job in progress
error Connection or print error
healthCheckFailed Health check failed

Health Checks

// Enable periodic health checks (every 5 minutes)
engine.enableHealthChecks(interval: Duration(minutes: 5));

// Disable health checks
engine.disableHealthChecks();

// Manual health check for specific printer
final isHealthy = await engine.checkPrinterHealth('printer-1');

// Check all printers
final results = await engine.checkAllPrinters();
// Returns: {'printer-1': true, 'printer-2': false}

Status Monitoring

// Listen to status events
engine.statusStream.listen((event) {
  print('Printer ${event.printerId}: ${event.oldStatus} -> ${event.newStatus}');

  if (event.newStatus == PrinterStatus.error) {
    showErrorNotification('Printer ${event.printerId} has an error');
  }
});

PrintResult

Property Type Description
totalPrinters int Total printers attempted
successCount int Successful prints
failedCount int Failed prints
results Map Per-printer results
isSuccess bool All succeeded
isPartialSuccess bool Some succeeded
isFailure bool All failed

PrinterResult

Property Type Description
printerId String Printer ID
success bool Print succeeded
errorMessage String? Error description
duration Duration Time taken

Configuration

Paper Sizes

Size Constant Use Case
PaperSize.mm58 58mm width Compact receipts
PaperSize.mm80 80mm width Standard receipts
// 58mm paper (compact receipts)
final printer58 = WiFiPrinterDevice(
  id: 'compact-1',
  name: 'Compact Printer',
  ipAddress: '192.168.1.101',
  paperSize: PaperSize.mm58,
);

// 80mm paper (standard receipts)
final printer80 = WiFiPrinterDevice(
  id: 'standard-1',
  name: 'Standard Printer',
  ipAddress: '192.168.1.102',
  paperSize: PaperSize.mm80,
);

Automatic Paper Size Conversion:

If ticket paper size differs from printer paper size, provide a regenerator function:

await engine.print(
  ticket: ticket, // Created for mm80
  regenerator: (paperSize) async {
    // Regenerate ticket for printer's paper size
    return generateTicket(order, paperSize);
  },
);

Printer Roles

Role Constant Description
Kitchen PrinterRole.kitchen Kitchen order tickets
Receipt PrinterRole.receipt Customer receipts
Both PrinterRole.both Handles any print job

Role Mapping (for role-based routing):

engine.setRoleMapping(PrinterRole.kitchen, ['bt-1', 'bt-2']);
engine.setRoleMapping(PrinterRole.receipt, ['wifi-1']);

// Get role mapping
final kitchenIds = engine.getRoleMapping(PrinterRole.kitchen);

Copy Control

Parameter Scope Description
defaultCopies Per-printer Default copies for every job sent to this printer
copies Per-job Overrides defaultCopies for all target printers in this job
// Per-printer default copies
final kitchenPrinter = BluetoothPrinterDevice(
  id: 'kitchen-1',
  name: 'Kitchen',
  macAddress: '00:11:22:33:44:55',
  defaultCopies: 2, // Kitchen always prints 2 copies
);

// Print with defaults (uses printer's defaultCopies)
await engine.print(ticket: ticket);

// Override: print 3 copies on all target printers
await engine.print(ticket: ticket, copies: 3);

// Role-based with override
await engine.print(
  ticket: ticket,
  targetRole: PrinterRole.kitchen,
  copies: 1, // Override kitchen's default of 2
);

Persistence

The package is in-memory only. Use export/import for persistence with your preferred storage:

import 'dart:convert';

// Save configuration (on app pause/exit)
final config = engine.exportConfiguration();
await storage.save('printer_config', jsonEncode(config));

// Load configuration (on app start)
final configJson = await storage.load('printer_config');
if (configJson != null) {
  final config = jsonDecode(configJson);
  engine.importConfiguration(config);
}

Exported Data:

  • All registered printers (type, name, address, role, defaultCopies, paperSize, metadata)
  • Printing mode (printToAll, roleBasedRouting, selectPerPrint)
  • Role mappings (which printers for which roles)

Storage Options: shared_preferences, get_storage, Hive, SQLite, backend API - your choice.


Usage Patterns

Complete Kitchen/Receipt Setup

import 'package:fifty_printing_engine/fifty_printing_engine.dart';
import 'package:escpos/escpos.dart';

class PrintingService {
  final engine = PrintingEngine.instance;
  late CapabilityProfile profile;

  Future<void> initialize() async {
    profile = await CapabilityProfile.load();

    // Register kitchen printer (Bluetooth)
    engine.registerPrinter(BluetoothPrinterDevice(
      id: 'kitchen-1',
      name: 'Kitchen Printer',
      macAddress: '00:11:22:33:44:55',
      role: PrinterRole.kitchen,
      paperSize: PaperSize.mm80,
      defaultCopies: 2,
    ));

    // Register receipt printer (WiFi)
    engine.registerPrinter(WiFiPrinterDevice(
      id: 'receipt-1',
      name: 'Receipt Printer',
      ipAddress: '192.168.1.100',
      port: 9100,
      role: PrinterRole.receipt,
      paperSize: PaperSize.mm80,
      defaultCopies: 1,
    ));

    // Configure role-based routing
    engine.setPrintingMode(PrintingMode.roleBasedRouting);

    // Enable health monitoring
    engine.enableHealthChecks(interval: Duration(minutes: 5));

    // Monitor status
    engine.statusStream.listen((event) {
      print('${event.printerId}: ${event.newStatus}');
    });
  }

  Future<void> printKitchenOrder(Order order) async {
    final ticket = _createKitchenTicket(order);
    final result = await engine.print(
      ticket: ticket,
      targetRole: PrinterRole.kitchen,
    );

    if (!result.isSuccess) {
      _handlePrintFailure(result);
    }
  }

  Future<void> printReceipt(Order order) async {
    final ticket = _createReceiptTicket(order);
    final result = await engine.print(
      ticket: ticket,
      targetRole: PrinterRole.receipt,
    );

    if (!result.isSuccess) {
      _handlePrintFailure(result);
    }
  }

  PrintTicket _createKitchenTicket(Order order) {
    final ticket = PrintTicket(PaperSize.mm80, profile);
    ticket.text('ORDER #${order.id}', styles: PosStyles(
      bold: true,
      height: PosTextSize.size2,
      align: PosAlign.center,
    ));
    ticket.text('Table: ${order.table}');
    ticket.hr();
    for (final item in order.items) {
      ticket.row([
        PosColumn(text: item.name, width: 8),
        PosColumn(text: 'x${item.quantity}', width: 4),
      ]);
    }
    ticket.feed(2);
    ticket.cut();
    return ticket;
  }

  PrintTicket _createReceiptTicket(Order order) {
    final ticket = PrintTicket(PaperSize.mm80, profile);
    ticket.text('RECEIPT', styles: PosStyles(bold: true, align: PosAlign.center));
    ticket.text('Order #${order.id}');
    ticket.hr();
    for (final item in order.items) {
      ticket.row([
        PosColumn(text: item.name, width: 6),
        PosColumn(text: 'x${item.quantity}', width: 2),
        PosColumn(text: '\$${item.price}', width: 4),
      ]);
    }
    ticket.hr();
    ticket.text('Total: \$${order.total}', styles: PosStyles(bold: true));
    ticket.feed(2);
    ticket.cut();
    return ticket;
  }

  void _handlePrintFailure(PrintResult result) {
    result.results.forEach((id, printerResult) {
      if (!printerResult.success) {
        print('Printer $id failed: ${printerResult.errorMessage}');
      }
    });
  }
}

Handling Print Results

final result = await engine.print(ticket: ticket);

if (result.isSuccess) {
  // All printers succeeded
  print('Printed to ${result.successCount} printer(s)');
} else if (result.isPartialSuccess) {
  // Some succeeded, some failed
  print('Partial: ${result.successCount}/${result.totalPrinters} succeeded');

  result.results.forEach((id, printerResult) {
    if (!printerResult.success) {
      print('$id failed: ${printerResult.errorMessage}');
      print('Duration: ${printerResult.duration}');
    }
  });
} else if (result.isFailure) {
  // All printers failed
  print('All ${result.totalPrinters} printer(s) failed');
}

Auto-Connect Behavior

The package attempts ALL registered printers, regardless of connection status:

// 2 printers: WiFi (connected), Bluetooth (disconnected)
final result = await engine.print(ticket: ticket);

// Bluetooth printer auto-attempts reconnection:
// - If reconnection succeeds: prints successfully
// - If reconnection fails: shows in results with error

// result.totalPrinters = 2 (both attempted)
// result.successCount = 1 (WiFi succeeded)
// result.failedCount = 1 (Bluetooth couldn't reconnect)

To exclude a printer from print jobs, unregister it:

engine.removePrinter('printer-id'); // Now it won't be attempted

Platform Support

Platform Bluetooth WiFi/Network Status
Android Supported Supported Full support
iOS Supported Supported Full support
macOS Supported Supported Full support
Windows Supported Supported Full support
Linux Not supported Supported WiFi only
Web Not supported Not supported Not supported

Android Setup

Add to android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />

iOS Setup

Add to ios/Runner/Info.plist:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app uses Bluetooth to connect to thermal printers.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>This app uses Bluetooth to connect to thermal printers.</string>

macOS Setup

Bluetooth printing on macOS requires entitlements. Ensure your macOS Runner is configured with:

  • com.apple.security.device.bluetooth entitlement
  • Bluetooth usage description in Info.plist

macOS Bluetooth support was added in print_bluetooth_thermal 1.1.8.

Platform Notes

  • Bluetooth is provided by print_bluetooth_thermal. Supported on Android, iOS, macOS, and Windows. Linux Bluetooth (BlueZ) is not yet implemented.
  • WiFi/Network printing uses Dart's built-in dart:io Socket on TCP port 9100. Works on all platforms except Web.
  • Android 12+ requires BLUETOOTH_CONNECT and BLUETOOTH_SCAN permissions. The package checks permissions but cannot request them programmatically due to an Android limitation. Users must grant access via App Settings.
  • Web has no raw socket or Bluetooth API access, so printing is not possible.

Fifty Design Language Integration

This package is part of Fifty Flutter Kit:

  • Consistent naming - PrintingEngine follows Fifty Flutter Kit patterns
  • Storage-agnostic - Export/import works with any Fifty storage solution
  • Compatible packages - Works alongside fifty_ui for printer management UIs

Version

Current: 1.0.3


License

MIT License - see LICENSE for details.

Part of Fifty Flutter Kit.

Libraries

fifty_printing_engine
Fifty Flutter Kit printing engine for multi-printer ESC/POS printing