diff --git a/lib/infrastructure/printer/datasource/network_printer_client.dart b/lib/infrastructure/printer/datasource/network_printer_client.dart new file mode 100644 index 0000000..ce9b006 --- /dev/null +++ b/lib/infrastructure/printer/datasource/network_printer_client.dart @@ -0,0 +1,271 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +/// Kenapa sebuah cetakan jaringan dianggap gagal. +enum NetworkPrintFailure { + /// Socket tidak bisa dibuka — IP salah, printer mati, atau beda jaringan. + unreachable, + + /// Printer menjawab bahwa kertasnya habis. + paperOut, + + /// Printer menjawab bahwa cover-nya terbuka. + coverOpen, + + /// Printer menjawab sedang offline / dalam kondisi error. + offline, + + /// Koneksi terbuka tapi pengiriman data putus di tengah jalan. + writeFailed, +} + +class NetworkPrintResult { + final NetworkPrintFailure? failure; + + /// Printer membalas perintah status. Printer clone yang tidak mendukung + /// `DLE EOT` membuat ini `false`, dan pemeriksaan kondisi kertas dilewati. + final bool statusSupported; + + /// Kertas menipis. Bukan kegagalan — cetakan tetap keluar, tapi layak + /// diberitahukan ke kasir. + final bool paperNearEnd; + + const NetworkPrintResult({ + this.failure, + this.statusSupported = false, + this.paperNearEnd = false, + }); + + bool get isSuccess => failure == null; +} + +/// Mencetak ke printer thermal jaringan lewat raw socket port 9100. +/// +/// Berbeda dari `flutter_esc_pos_network` yang hanya mengirim byte lalu menutup +/// koneksi, klien ini menanyakan kondisi printer dengan `DLE EOT` dan membaca +/// jawabannya. Tanpa itu, kertas habis atau cover terbuka tidak bisa dibedakan +/// dari cetakan yang berhasil — byte tetap diterima socket dan semuanya tampak +/// sukses. +class NetworkPrinterClient { + static const _logName = 'NetworkPrinterClient'; + + /// Perintah status real-time ESC/POS: `DLE EOT n`. + static const _statusPrinter = [0x10, 0x04, 1]; + static const _statusOffline = [0x10, 0x04, 2]; + static const _statusPaper = [0x10, 0x04, 4]; + + /// Jeda menunggu balasan status. Sengaja pendek: printer yang tidak + /// mendukung `DLE EOT` tidak akan pernah menjawab, dan kita tidak boleh + /// menahan kasir hanya untuk menunggu jawaban yang tidak akan datang. + static const _statusTimeout = Duration(milliseconds: 800); + + /// Host yang terbukti tidak menjawab `DLE EOT`. Tanpa catatan ini, printer + /// clone membuat setiap cetakan membayar timeout di atas — mahal untuk kasir + /// yang sedang antre. Cukup dibuktikan sekali per sesi aplikasi. + final Set _statusUnsupported = {}; + + Future printTicket({ + required String host, + required List data, + int port = 9100, + Duration connectTimeout = const Duration(seconds: 10), + }) async { + Socket? socket; + + try { + socket = await Socket.connect(host, port, timeout: connectTimeout); + } catch (e) { + log('Tidak bisa membuka socket ke $host:$port', name: _logName, error: e); + return const NetworkPrintResult( + failure: NetworkPrintFailure.unreachable, + ); + } + + final reader = _SocketReader(); + late StreamSubscription> subscription; + + try { + subscription = socket.listen( + reader.add, + onError: (Object e) => log('Socket error', name: _logName, error: e), + cancelOnError: false, + ); + + final before = await _readStatus(socket, reader, host); + + if (before.answered) { + final blocking = before.blockingFailure; + if (blocking != null) { + log('Printer $host menolak: ${blocking.name}', name: _logName); + return NetworkPrintResult(failure: blocking, statusSupported: true); + } + } + + socket.add(data); + await socket.flush(); + + // Kertas bisa habis di tengah struk. Cetakan yang terpotong sama + // tidak bergunanya dengan yang tidak keluar sama sekali, jadi kondisi + // ini dilaporkan sebagai gagal supaya kasir mencetak ulang. + final after = before.answered + ? await _readStatus(socket, reader, host) + : const _PrinterStatus.unanswered(); + + if (after.answered) { + final blocking = after.blockingFailure; + if (blocking != null) { + log('Printer $host bermasalah setelah cetak: ${blocking.name}', + name: _logName); + return NetworkPrintResult(failure: blocking, statusSupported: true); + } + } + + return NetworkPrintResult( + statusSupported: before.answered, + paperNearEnd: after.paperNearEnd || before.paperNearEnd, + ); + } catch (e) { + log('Gagal mengirim data ke $host:$port', name: _logName, error: e); + return const NetworkPrintResult( + failure: NetworkPrintFailure.writeFailed, + statusSupported: false, + ); + } finally { + await subscription.cancel().catchError((_) {}); + try { + await socket.close(); + } catch (_) { + // Printer kadang menutup duluan; tidak ada yang bisa diperbuat. + } + socket.destroy(); + } + } + + Future<_PrinterStatus> _readStatus( + Socket socket, + _SocketReader reader, + String host, + ) async { + if (_statusUnsupported.contains(host)) { + return const _PrinterStatus.unanswered(); + } + + final printer = await _query(socket, reader, _statusPrinter); + + // Printer yang tidak mendukung DLE EOT tidak menjawab sama sekali. + // Perlakukan sebagai "tidak diketahui", bukan sebagai error — kalau tidak, + // printer semacam itu jadi tidak bisa dipakai sama sekali. + if (printer == null) { + log( + 'Printer $host tidak menjawab status; pemeriksaan kondisi kertas ' + 'dilewati untuk seterusnya', + name: _logName, + ); + _statusUnsupported.add(host); + return const _PrinterStatus.unanswered(); + } + + final offline = await _query(socket, reader, _statusOffline); + final paper = await _query(socket, reader, _statusPaper); + + return _PrinterStatus( + answered: true, + offline: printer & 0x08 != 0, + coverOpen: (offline ?? 0) & 0x04 != 0, + paperOut: ((offline ?? 0) & 0x20 != 0) || ((paper ?? 0) & 0x60 != 0), + paperNearEnd: (paper ?? 0) & 0x0C != 0, + ); + } + + /// Mengirim satu perintah status dan menunggu satu byte balasan. + /// `null` berarti printer tidak menjawab atau menjawab dengan format asing. + Future _query( + Socket socket, + _SocketReader reader, + List command, + ) async { + reader.clear(); + socket.add(command); + await socket.flush(); + + final byte = await reader.readByte(_statusTimeout); + if (byte == null) return null; + + // Balasan DLE EOT selalu punya pola tetap: bit0=0, bit1=1, bit4=1, bit7=0. + // Byte yang tidak cocok berarti bukan balasan status, jadi lebih aman + // dianggap tidak menjawab daripada salah tafsir. + if (byte & 0x93 != 0x12) { + log('Balasan status tidak dikenali: 0x${byte.toRadixString(16)}', + name: _logName); + return null; + } + + return byte; + } +} + +class _PrinterStatus { + final bool answered; + final bool offline; + final bool coverOpen; + final bool paperOut; + final bool paperNearEnd; + + const _PrinterStatus({ + required this.answered, + this.offline = false, + this.coverOpen = false, + this.paperOut = false, + this.paperNearEnd = false, + }); + + const _PrinterStatus.unanswered() + : answered = false, + offline = false, + coverOpen = false, + paperOut = false, + paperNearEnd = false; + + /// Kondisi yang membuat struk tidak akan keluar. Diperiksa berurutan dari + /// yang paling spesifik supaya pesan ke kasir langsung menyebut tindakannya. + NetworkPrintFailure? get blockingFailure { + if (paperOut) return NetworkPrintFailure.paperOut; + if (coverOpen) return NetworkPrintFailure.coverOpen; + if (offline) return NetworkPrintFailure.offline; + return null; + } +} + +/// Menampung byte yang datang dari socket dan membiarkannya dibaca satu per +/// satu dengan batas waktu. Socket adalah stream sekali-langganan, jadi +/// pembacaannya harus lewat satu subscription untuk seluruh sesi. +class _SocketReader { + final List _buffer = []; + Completer? _waiter; + + void add(List data) { + _buffer.addAll(data); + _waiter?.complete(); + _waiter = null; + } + + void clear() => _buffer.clear(); + + Future readByte(Duration timeout) async { + if (_buffer.isEmpty) { + final waiter = Completer(); + _waiter = waiter; + + try { + await waiter.future.timeout(timeout); + } on TimeoutException { + _waiter = null; + return null; + } + } + + if (_buffer.isEmpty) return null; + return _buffer.removeAt(0); + } +} diff --git a/lib/infrastructure/printer/repositories/printer_repository.dart b/lib/infrastructure/printer/repositories/printer_repository.dart index 9f29afb..1fe71a2 100644 --- a/lib/infrastructure/printer/repositories/printer_repository.dart +++ b/lib/infrastructure/printer/repositories/printer_repository.dart @@ -3,7 +3,6 @@ import 'dart:developer'; import 'package:dartz/dartz.dart' hide Order; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; -import 'package:flutter_esc_pos_network/flutter_esc_pos_network.dart'; import 'package:injectable/injectable.dart' hide Order; import 'package:permission_handler/permission_handler.dart'; import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart'; @@ -17,6 +16,7 @@ import '../../../presentation/components/print/print_ui.dart'; import '../../auth/datasources/local_data_provider.dart'; import '../../outlet/datasources/local_data_provider.dart'; import '../datasource/local_data_provider.dart'; +import '../datasource/network_printer_client.dart'; import '../printer_dtos.dart'; @LazySingleton(as: IPrinterRepository) @@ -40,6 +40,11 @@ class PrinterRepository implements IPrinterRepository { /// tiket dua order tidak saling menyelip. final Lock _jobLock = Lock(); + /// Menggantikan `flutter_esc_pos_network` yang hanya mengirim byte lalu + /// menutup koneksi. Klien ini menanyakan kondisi printer dan membaca + /// jawabannya, sehingga kertas habis tidak lagi terlihat sebagai sukses. + final NetworkPrinterClient _networkClient = NetworkPrinterClient(); + String? _connectedMac; @override @@ -484,31 +489,45 @@ class PrinterRepository implements IPrinterRepository { List printData, ) async { try { - final printResult = await _printNetwork(printer.address, printData); + final result = await _networkClient.printTicket( + host: printer.address, + data: printData, + ); - if (!printResult) { - FirebaseCrashlytics.instance.recordError( - 'Failed to print to Network Printer', - null, - reason: 'Failed to print to Network Printer', - information: [ - 'function: _printNetwork(printer.address, printData)', - 'Printer: ${printer.name}', - 'ipAddress: ${printer.address}', - 'bytes: ${printData.length}', - 'in: $_logName', - ], - ); - // Dulu ini `right(false)`, yang lolos sebagai sukses ke pemanggil — - // kasir melihat "berhasil dicetak" padahal tidak ada kertas keluar. - return left( - PrinterFailure.dynamicErrorMessage( - 'Tidak bisa mencetak ke ${printer.name} (${printer.address})', - ), + if (result.isSuccess) { + if (result.paperNearEnd) { + log('Kertas ${_printerLabel(printer)} menipis', name: _logName); + } + + log( + 'Cetak ${_printerLabel(printer)} selesai — kondisi printer ' + '${result.statusSupported ? "dikonfirmasi" : "tidak bisa ditanyakan"}', + name: _logName, ); + + return right(true); } - return right(true); + final failure = result.failure!; + + FirebaseCrashlytics.instance.recordError( + 'Network print failed: ${failure.name}', + null, + reason: 'Network print failed: ${failure.name}', + information: [ + 'Printer: ${printer.name}', + 'ipAddress: ${printer.address}', + 'bytes: ${printData.length}', + 'statusSupported: ${result.statusSupported}', + 'in: $_logName', + ], + ); + + return left( + PrinterFailure.dynamicErrorMessage( + _networkFailureMessage(printer, failure), + ), + ); } catch (e, stackTrace) { FirebaseCrashlytics.instance.recordError( e, @@ -523,45 +542,34 @@ class PrinterRepository implements IPrinterRepository { } } - Future _printNetwork(String ipAddress, List printData) async { - try { - final printer = PrinterNetworkManager( - ipAddress, - port: 9100, - timeout: const Duration(seconds: 15), - ); + /// Pesan yang menyebut tindakan yang harus dilakukan kasir, bukan istilah + /// teknis — orang di depan mesin perlu tahu harus mengganti kertas atau + /// menutup cover, bukan membaca kode error. + String _networkFailureMessage(Printer printer, NetworkPrintFailure failure) { + final label = _printerLabel(printer); - final PosPrintResult connect = await printer.connect(); - if (connect != PosPrintResult.success) { - log('Failed to connect to Network printer: ${connect.msg}'); - return false; - } - - final PosPrintResult printing = await printer.printTicket(printData); - printer.disconnect(); - - if (printing != PosPrintResult.success) { - log('Failed to print via Network printer: ${printing.msg}'); - return false; - } - - log('Successfully printed via Network printer: $ipAddress'); - return true; - } catch (e, stackTrace) { - FirebaseCrashlytics.instance.recordError( - e, - stackTrace, - reason: 'Error printing via Network', - information: [ - 'function: _printNetwork(String ipAddress, List printData)', - 'ipAddress: $ipAddress', - ], - ); - log('Error printing via Network: $e'); - return false; - } + return switch (failure) { + NetworkPrintFailure.unreachable => + 'Printer $label tidak terjangkau. Pastikan printer menyala dan berada di jaringan yang sama dengan tablet.', + NetworkPrintFailure.paperOut => + 'Kertas di printer $label habis. Ganti kertasnya, lalu cetak ulang.', + NetworkPrintFailure.coverOpen => + 'Cover printer $label terbuka. Tutup covernya, lalu cetak ulang.', + NetworkPrintFailure.offline => + 'Printer $label sedang offline atau dalam kondisi error. Periksa printernya, lalu cetak ulang.', + NetworkPrintFailure.writeFailed => + 'Pengiriman data ke printer $label terputus di tengah jalan. Cetak ulang untuk memastikan strukmya utuh.', + }; } + /// Nama printer di database sering kosong, jadi alamatnya selalu ikut + /// disebut supaya pesan errornya tetap bisa ditindaklanjuti. + String _printerLabel(Printer printer) { + final name = printer.name.trim(); + return name.isEmpty ? printer.address : '$name (${printer.address})'; + } + + Future _writeBytesChunked(List data) async { for (int i = 0; i < data.length; i += _kChunkSize) { final end = i + _kChunkSize > data.length ? data.length : i + _kChunkSize; @@ -683,6 +691,16 @@ class PrinterRepository implements IPrinterRepository { final result = await _printStation(plan, outlet, user.name); stopwatch.stop(); + // Jejak per station ikut di setiap laporan Crashlytics berikutnya, + // jadi saat ada keluhan "tiket tidak keluar" kita punya urutan + // kejadiannya, bukan cuma satu error tanpa konteks. + FirebaseCrashlytics.instance.log( + 'print ${plan.station.name} ' + 'via ${plan.printer.type}(${plan.printer.address}) ' + '${result.isLeft() ? "GAGAL" : "terkirim"} ' + 'dalam ${stopwatch.elapsedMilliseconds}ms', + ); + mark( plan.station, result.fold(