import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart'; final bluetoothPrintServiceProvider = Provider( (ref) => BluetoothPrintService(), ); /// Service that wraps [PrintBluetoothThermal] to scan, connect, and print /// ESC/POS tickets on classic-Bluetooth thermal printers (Android). class BluetoothPrintService { bool _connected = false; // --------------------------------------------------------------------------- // Connection // --------------------------------------------------------------------------- bool get isConnected => _connected; /// Returns paired Bluetooth devices (Android) or nearby devices (iOS). Future> scanDevices() async { final hasPermission = await PrintBluetoothThermal.isPermissionBluetoothGranted; if (!hasPermission) { return []; } final enabled = await PrintBluetoothThermal.bluetoothEnabled; if (!enabled) { return []; } return PrintBluetoothThermal.pairedBluetooths; } /// Connect to a printer by its MAC address. Returns `true` on success. Future connect(String macAddress) async { final result = await PrintBluetoothThermal.connect(macPrinterAddress: macAddress); _connected = result; return result; } /// Disconnect from the current printer. Future disconnect() async { await PrintBluetoothThermal.disconnect; _connected = false; } /// Refresh the internal connection flag from the native layer. Future refreshConnectionStatus() async { _connected = await PrintBluetoothThermal.connectionStatus; return _connected; } // --------------------------------------------------------------------------- // Printing // --------------------------------------------------------------------------- /// Print a ticket from the structured payload returned by /// `documents/{id}/print-payload`. /// /// If the payload contains a `text` key the pre-formatted plain text is sent /// directly. Otherwise, the ticket is built from `header`, `customer`, /// `items`, `totals`, and `footer` sections. Future printTicket(Map printPayload) async { if (!_connected) { final live = await PrintBluetoothThermal.connectionStatus; if (!live) { throw StateError( 'No hay impresora conectada. Conecta una impresora antes de imprimir.', ); } _connected = true; } final List bytes; final preformatted = printPayload['text']; if (preformatted is String && preformatted.trim().isNotEmpty) { bytes = _buildFromPlainText(preformatted); } else { bytes = _buildFromStructured(printPayload); } final writeOk = await PrintBluetoothThermal.writeBytes(bytes).timeout( const Duration(seconds: 12), onTimeout: () => false, ); if (writeOk != true) { throw StateError( 'La impresora no respondio. Verifica conexion Bluetooth y estado de la impresora.', ); } } /// Print a short test ticket to verify the connection. Future printTest() async { await printTicket({ 'text': '\n' '================================\n' ' IMPRESION DE PRUEBA\n' '================================\n' '\n' ' SysFarma v1\n' ' Bluetooth OK\n' '\n' '================================\n' '\n\n\n', }); } // --------------------------------------------------------------------------- // ESC/POS byte builders // --------------------------------------------------------------------------- /// Standard 48-column thermal printer width. static const int _lineWidth = 48; // ESC/POS command constants. static const List _escInit = [0x1B, 0x40]; // ESC @ – initialize static const List _boldOn = [0x1B, 0x45, 0x01]; // ESC E 1 static const List _boldOff = [0x1B, 0x45, 0x00]; // ESC E 0 static const List _alignCenter = [0x1B, 0x61, 0x01]; // ESC a 1 static const List _alignLeft = [0x1B, 0x61, 0x00]; // ESC a 0 static const List _sizeDouble = [0x1D, 0x21, 0x11]; // GS ! 0x11 static const List _sizeNormal = [0x1D, 0x21, 0x00]; // GS ! 0x00 static const List _cutPaper = [0x1D, 0x56, 0x00]; // GS V 0 – full cut static const List _feedLines = [0x1B, 0x64, 0x04]; // ESC d 4 List _buildFromPlainText(String text) { final buffer = []; buffer.addAll(_escInit); buffer.addAll(_sizeNormal); buffer.addAll(_alignLeft); buffer.addAll(_encode(text)); buffer.addAll(_feedLines); buffer.addAll(_cutPaper); return buffer; } List _buildFromStructured(Map payload) { final buffer = []; buffer.addAll(_escInit); // ---- Header / company info ---- final header = Map.from(payload['header'] as Map? ?? const {}); final companyName = header['company_name']?.toString() ?? header['trade_name']?.toString(); if (companyName != null && companyName.isNotEmpty) { buffer.addAll(_alignCenter); buffer.addAll(_sizeDouble); buffer.addAll(_boldOn); buffer.addAll(_encode('$companyName\n')); buffer.addAll(_boldOff); buffer.addAll(_sizeNormal); } final ruc = header['ruc']?.toString(); if (ruc != null && ruc.isNotEmpty) { buffer.addAll(_alignCenter); buffer.addAll(_encode('RUC: $ruc\n')); } final address = header['address']?.toString(); if (address != null && address.isNotEmpty) { buffer.addAll(_alignCenter); buffer.addAll(_encode('$address\n')); } final phone = header['phone']?.toString(); if (phone != null && phone.isNotEmpty) { buffer.addAll(_alignCenter); buffer.addAll(_encode('Tel: $phone\n')); } buffer.addAll(_encode(_separator())); // ---- Document type + number ---- final docType = header['document_type']?.toString() ?? ''; final docNumber = header['number']?.toString() ?? ''; if (docType.isNotEmpty || docNumber.isNotEmpty) { buffer.addAll(_alignCenter); buffer.addAll(_boldOn); buffer.addAll(_encode('$docType\n$docNumber\n')); buffer.addAll(_boldOff); } final date = header['date']?.toString(); if (date != null && date.isNotEmpty) { buffer.addAll(_alignCenter); buffer.addAll(_encode('Fecha: $date\n')); } buffer.addAll(_encode(_separator())); // ---- Customer ---- final customer = Map.from(payload['customer'] as Map? ?? const {}); if (customer.isNotEmpty) { buffer.addAll(_alignLeft); final customerName = customer['name']?.toString(); final customerDoc = customer['number']?.toString(); if (customerName != null && customerName.isNotEmpty) { buffer.addAll(_encode('Cliente: $customerName\n')); } if (customerDoc != null && customerDoc.isNotEmpty) { buffer.addAll(_encode('Doc: $customerDoc\n')); } buffer.addAll(_encode(_separator())); } // ---- Items ---- final items = List>.from( payload['items'] as List? ?? const [], ); if (items.isNotEmpty) { buffer.addAll(_alignLeft); buffer.addAll(_boldOn); buffer.addAll(_encode(_columnsRow('DESCRIPCION', 'SUBT.'))); buffer.addAll(_boldOff); buffer.addAll(_encode(_separator())); for (final item in items) { final description = item['description']?.toString() ?? item['name']?.toString() ?? ''; final qty = _fmtNum(item['quantity']); final price = _fmtNum(item['unit_price'] ?? item['price']); final subtotal = _fmtNum(item['subtotal'] ?? item['total']); // First line: description (may wrap) buffer.addAll(_alignLeft); buffer.addAll(_encode('$description\n')); // Second line: qty x price = subtotal (right-aligned summary) final detail = ' $qty x $price'; buffer.addAll(_encode(_columnsRow(detail, subtotal))); } buffer.addAll(_encode(_separator())); } // ---- Totals ---- final totals = Map.from(payload['totals'] as Map? ?? const {}); if (totals.isNotEmpty) { buffer.addAll(_alignLeft); for (final entry in totals.entries) { final label = _totalLabel(entry.key); final value = _fmtNum(entry.value); buffer.addAll(_encode(_columnsRow(label, value))); } // Grand total bold final grandTotal = totals['total'] ?? totals['grand_total']; if (grandTotal != null) { buffer.addAll(_boldOn); buffer.addAll(_encode( _columnsRow('TOTAL', _fmtNum(grandTotal)), )); buffer.addAll(_boldOff); } buffer.addAll(_encode(_separator())); } // ---- Footer ---- final footer = Map.from(payload['footer'] as Map? ?? const {}); if (footer.isNotEmpty) { buffer.addAll(_alignCenter); final hash = footer['hash']?.toString(); if (hash != null && hash.isNotEmpty) { buffer.addAll(_encode('Hash: $hash\n')); } final legend = footer['legend']?.toString(); if (legend != null && legend.isNotEmpty) { buffer.addAll(_encode('$legend\n')); } } // ---- Feed + cut ---- buffer.addAll(_feedLines); buffer.addAll(_cutPaper); return buffer; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /// Encode text to bytes using Latin-1 (ISO 8859-1) which is what most /// thermal printers expect. Characters outside the range are replaced. Uint8List _encode(String text) { return Uint8List.fromList(latin1.encode( text.replaceAll(RegExp(r'[^\x00-\xFF]'), '?'), )); } String _separator() => '${'=' * _lineWidth}\n'; /// Build a two-column row: label left, value right, padded to [_lineWidth]. String _columnsRow(String left, String right) { final maxLeft = _lineWidth - right.length - 1; final truncatedLeft = left.length > maxLeft ? left.substring(0, maxLeft) : left; final padding = _lineWidth - truncatedLeft.length - right.length; return '$truncatedLeft${' ' * (padding > 0 ? padding : 1)}$right\n'; } /// Format a number from the API (could be num, String, or null). String _fmtNum(dynamic value) { if (value == null) return '0.00'; final parsed = double.tryParse(value.toString()); if (parsed == null) return value.toString(); return parsed.toStringAsFixed(2); } /// Map total keys to human-readable labels. String _totalLabel(String key) { switch (key) { case 'subtotal': case 'sub_total': return 'Subtotal'; case 'total_tax': case 'total_igv': return 'IGV'; case 'total_discount': return 'Descuento'; case 'total_exonerated': return 'Exonerado'; case 'total_unaffected': return 'Inafecto'; case 'total_free': return 'Gratuito'; case 'total': case 'grand_total': return 'TOTAL'; default: return key.replaceAll('_', ' ').toUpperCase(); } } }