import 'dart:async'; import 'dart:developer'; import 'package:dartz/dartz.dart' hide Order; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:injectable/injectable.dart' hide Order; import 'package:permission_handler/permission_handler.dart'; import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart'; import 'package:synchronized/synchronized.dart'; import '../../../common/extension/extension.dart'; import '../../../domain/order/order.dart'; import '../../../domain/outlet/outlet.dart'; import '../../../domain/printer/printer.dart'; 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) class PrinterRepository implements IPrinterRepository { final PrinterLocalDataProvider _localDataProvider; final OutletLocalDatasource _outletLocalDatasource; final AuthLocalDataProvider _authLocalDataProvider; final _logName = 'PrinterRepository'; PrinterRepository( this._localDataProvider, this._outletLocalDatasource, this._authLocalDataProvider, ); /// Seluruh cetakan Bluetooth berbagi satu lock: radionya cuma satu, jadi /// lock per alamat tidak menahan dua printer Bluetooth yang berbeda. final Lock _bluetoothLock = Lock(); /// Menjaga satu job cetak selesai sebelum job berikutnya mulai, supaya /// 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 Future> connectBluetooth(String macAddress) { // Memakai jalur yang sama dengan mencetak: retry, penutupan socket basi, // dan pencatatan `_connectedMac`. Versi sebelumnya mengembalikan sukses // begitu ADA koneksi, tanpa memeriksa apakah itu printer yang diminta — // menyambungkan printer B saat A tersambung akan tetap "berhasil". return _bluetoothLock.synchronized( () => _ensureBluetoothConnected(macAddress), ); } @override Future> disconectBluetooth() async { try { bool result = await PrintBluetoothThermal.disconnect; _connectedMac = null; log("Bluetooth printer disconnected: $result", name: _logName); return right(result); } catch (e) { _connectedMac = null; log("Error disconnecting Bluetooth printer", error: e, name: _logName); return left( const PrinterFailure.dynamicErrorMessage( 'Error disconnecting Bluetooth printer', ), ); } } @override Future>> getPairedBluetoothDevices() async { try { final result = await PrintBluetoothThermal.pairedBluetooths; log("Paired Bluetooth devices: $result", name: _logName); return right(result); } catch (e) { log("Error getting paired Bluetooth devices", name: _logName, error: e); return left( const PrinterFailure.dynamicErrorMessage( 'Error getting paired Bluetooth devices', ), ); } } @override Future> isBluetoothEnabled() async { try { final result = await PrintBluetoothThermal.bluetoothEnabled; return right(result); } catch (e) { log("Error checking Bluetooth status", name: _logName, error: e); return left( const PrinterFailure.dynamicErrorMessage( 'Error checking Bluetooth status', ), ); } } @override Future> createPrinter(Printer printer) async { try { final result = await _localDataProvider.createPrinter( PrinterDto.fromDomain(printer), ); if (result.hasError) { return left(result.error!); } return right(unit); } catch (e) { log('createPrinterError', name: _logName, error: e); return left(const PrinterFailure.unexpectedError()); } } @override Future> deletePrinter(int id) async { try { final result = await _localDataProvider.deletePrinter(id); if (result.hasError) { return left(result.error!); } return right(unit); } catch (e) { log('deletePrinterError', name: _logName, error: e); return left(const PrinterFailure.unexpectedError()); } } @override Future> getPrinterByCode(String code) async { try { final result = await _localDataProvider.findPrinterByCode(code); if (result.hasError) { return left(result.error!); } final printer = result.data!.toDomain(); return right(printer); } catch (e) { log('getPrinterByCodeError', name: _logName, error: e); return left(const PrinterFailure.unexpectedError()); } } @override Future> updatePrinter( Printer printer, int id, ) async { try { final result = await _localDataProvider.updatePrinter( PrinterDto.fromDomain(printer), id, ); if (result.hasError) { return left(result.error!); } return right(unit); } catch (e) { log('updatePrinterError', name: _logName, error: e); return left(const PrinterFailure.unexpectedError()); } } // =========================================================================== // Timing Bluetooth // // Radio Bluetooth cuma satu, jadi pindah printer berarti disconnect lalu // connect ulang. Tiga jeda di bawah melindungi hal yang berbeda; ketiganya // sempat dipangkas ke 300ms (atau dihapus) dan itu yang membuat tiket // station setelah perpindahan koneksi sering hilang. // =========================================================================== /// Jeda setelah byte terakhir dikirim, sebelum koneksi boleh diputus. /// `writeBytes` selesai saat byte masuk socket, bukan saat kertas keluar. static const _kPostPrintSettle = Duration(milliseconds: 500); /// Jeda setelah connect berhasil. Channel SPP dan firmware printer belum /// tentu siap saat `connect()` return; menulis terlalu cepat membuat /// byte-byte awal hilang. static const _kPostConnectSettle = Duration(milliseconds: 800); /// Batas menunggu link Bluetooth benar-benar lepas setelah disconnect. static const _kDisconnectTimeout = Duration(seconds: 2); /// Backoff antar percobaan connect. Panjangnya menentukan jumlah percobaan. /// Retry hanya berjalan saat gagal, jadi tidak menambah waktu di jalur normal. static const _kConnectBackoff = [ Duration(milliseconds: 500), Duration(milliseconds: 1200), ]; static const _kChunkSize = 1024; static const _kChunkDelay = Duration(milliseconds: 50); @override Future> printStruct( Printer printer, List printData, ) async { if (printer.type == 'Bluetooth') { return _bluetoothLock.synchronized( () => _printBluetooth(printer, printData), ); } return _printToNetworkPrinter(printer, printData); } Future> _printBluetooth( Printer printer, List printData, ) async { try { final connected = await _ensureBluetoothConnected(printer.address); if (connected.isLeft()) return connected; log( 'Printer connected to bluetooth: ${printer.name}, ${printer.address}', name: _logName, ); final printResult = await _writeBytesChunked(printData); log('Print result ${printer.name}: $printResult', name: _logName); if (!printResult) { // Tulis yang gagal di tengah jalan menyisakan koneksi dengan status // tidak jelas — paksa reconnect pada cetakan berikutnya. _connectedMac = null; FirebaseCrashlytics.instance.recordError( 'Failed to print to ${printer.name}', null, reason: 'Failed to write bytes to Bluetooth printer', information: [ 'function: _writeBytesChunked(printData)', 'Printer: ${printer.name}', 'MAC: ${printer.address}', 'bytes: ${printData.length}', 'in: $_logName', ], ); return left( PrinterFailure.dynamicErrorMessage( 'Gagal mengirim data ke ${printer.name}', ), ); } // Printer masih menghabiskan buffer-nya. Memutus koneksi sekarang // memotong cetakan, dan membuat connect berikutnya gagal. await Future.delayed(_kPostPrintSettle); return right(true); } catch (e, stackTrace) { _connectedMac = null; FirebaseCrashlytics.instance.recordError( e, stackTrace, reason: 'Error printing ${printer.name}', information: ['Printer: ${printer.name}', 'MAC: ${printer.address}'], ); log('Error printing bluetooth', name: _logName, error: e); return left( const PrinterFailure.dynamicErrorMessage('Error printing struct'), ); } } /// Memastikan radio tersambung ke [macAddress], reconnect kalau perlu. Future> _ensureBluetoothConnected( String macAddress, ) async { // Izin runtime Bluetooth hilang setiap kali app di-reinstall, dan sebelumnya // hanya diminta dari dialog pilih perangkat di halaman pengaturan. Kalau // kasir tidak pernah membuka dialog itu, `connect()` gagal terus tanpa // penjelasan. Dipastikan di sini supaya semua jalur cetak ikut terlindungi. final hasPermission = await _ensureBluetoothPermission(); final isConnected = await PrintBluetoothThermal.connectionStatus; final isAdapterOn = await _isBluetoothOn(); final isPaired = await _isPaired(macAddress); log( 'Bluetooth state: connected=$isConnected, ' 'connectedMac=${_connectedMac ?? "-"}, target=$macAddress, ' 'permission=$hasPermission, adapterOn=$isAdapterOn, paired=$isPaired', name: _logName, ); if (!hasPermission) { return left( const PrinterFailure.dynamicErrorMessage( 'Izin Bluetooth belum diberikan. Buka pengaturan aplikasi dan izinkan akses "Perangkat di sekitar", lalu cetak ulang.', ), ); } if (!isAdapterOn) { return left( const PrinterFailure.dynamicErrorMessage( 'Bluetooth di perangkat ini sedang mati. Nyalakan Bluetooth, lalu cetak ulang.', ), ); } // Mencoba connect ke perangkat yang tidak ter-pairing pasti gagal, jadi // jangan buang waktu tiga kali percobaan untuk itu. if (!isPaired) { return left( PrinterFailure.dynamicErrorMessage( 'Printer ($macAddress) tidak ada di daftar perangkat Bluetooth yang terpasang. Pasangkan lewat pengaturan Bluetooth Android, lalu hubungkan lagi di pengaturan printer.', ), ); } if (isConnected && _connectedMac == macAddress) { return right(true); } if (isConnected) { await _forceDisconnect(); } for (var attempt = 0; attempt <= _kConnectBackoff.length; attempt++) { // 'read failed, socket might closed or timeout' berarti socket RFCOMM // sebelumnya masih setengah terbuka. Connect ulang tanpa menutupnya // lebih dulu akan gagal dengan error yang sama terus, jadi tiap // percobaan ulang harus didahului disconnect. if (attempt > 0) await _forceDisconnect(); final connected = await PrintBluetoothThermal.connect( macPrinterAddress: macAddress, ); if (connected) { _connectedMac = macAddress; await Future.delayed(_kPostConnectSettle); return right(true); } if (attempt < _kConnectBackoff.length) { log( 'Connect ke $macAddress gagal ' '(percobaan ${attempt + 1}), tutup socket lalu ulangi', name: _logName, ); await Future.delayed(_kConnectBackoff[attempt]); } } _connectedMac = null; FirebaseCrashlytics.instance.recordError( 'Failed to connect to Bluetooth printer', null, reason: 'Failed to connect to Bluetooth printer after retries', information: [ 'function: _ensureBluetoothConnected(String macAddress)', 'macAddress: $macAddress', 'paired: $isPaired', 'attempts: ${_kConnectBackoff.length + 1}', 'in: $_logName', ], ); // Sampai di sini printer sudah dipastikan ter-pairing dan Bluetooth hidup, // jadi sisanya adalah printer yang tidak merespons. return left( PrinterFailure.dynamicErrorMessage( 'Printer ($macAddress) tidak merespons. Matikan lalu nyalakan printernya, pastikan tidak sedang tersambung ke perangkat atau aplikasi lain, lalu cetak ulang.', ), ); } /// Apakah radio Bluetooth perangkat ini menyala. Future _isBluetoothOn() async { try { return await PrintBluetoothThermal.bluetoothEnabled; } catch (e) { log('Gagal membaca status Bluetooth', name: _logName, error: e); return true; // jangan menghalangi cetak kalau kita tidak tahu } } /// Apakah [macAddress] masih terdaftar sebagai perangkat Bluetooth ter-pairing. Future _isPaired(String macAddress) async { try { final paired = await PrintBluetoothThermal.pairedBluetooths; final target = macAddress.toUpperCase(); log( 'Paired devices: ${paired.map((e) => "${e.name}=${e.macAdress}").join(", ")}', name: _logName, ); return paired.any((e) => e.macAdress.toUpperCase() == target); } catch (e) { log('Gagal membaca daftar paired device', name: _logName, error: e); return true; // jangan menuduh belum ter-pairing kalau kita tidak tahu } } /// Memastikan izin runtime Bluetooth ada, meminta ke user kalau belum. /// /// Di Android 12+ `BLUETOOTH_CONNECT` adalah izin runtime — tanpa itu /// `connect()` selalu gagal, dan errornya tidak menyebut soal izin sama /// sekali sehingga tampak seperti printer rusak. Future _ensureBluetoothPermission() async { try { var status = await Permission.bluetoothConnect.status; if (status.isDenied) { status = await Permission.bluetoothConnect.request(); } final scan = await Permission.bluetoothScan.status; if (scan.isDenied) await Permission.bluetoothScan.request(); if (!status.isGranted) { log('Izin bluetoothConnect: $status', name: _logName); } return status.isGranted; } catch (e) { log('Gagal memeriksa izin Bluetooth', name: _logName, error: e); // Perangkat di bawah Android 12 tidak mengenal izin ini — jangan // menghalangi cetak hanya karena pemeriksaannya gagal. return true; } } /// Menutup socket Bluetooth apa pun keadaannya, lalu menunggu link lepas. /// /// `disconnect` dipanggil walau `connectionStatus` bilang tidak tersambung: /// socket yang setengah terbuka sering melaporkan `false` padahal masih /// memblokir connect berikutnya. Future _forceDisconnect() async { try { await PrintBluetoothThermal.disconnect; } catch (e) { log('Disconnect gagal, diabaikan', name: _logName, error: e); } _connectedMac = null; await _waitUntilDisconnected(); } /// Teardown RFCOMM di Android berjalan asinkron: `disconnect` selesai /// sebelum link benar-benar lepas. Menunggu kondisinya (bukan durasi tetap) /// supaya perangkat yang cepat tidak ikut dihukum jeda panjang. Future _waitUntilDisconnected() async { final deadline = DateTime.now().add(_kDisconnectTimeout); while (DateTime.now().isBefore(deadline)) { if (!await PrintBluetoothThermal.connectionStatus) return; await Future.delayed(const Duration(milliseconds: 100)); } log( 'Bluetooth masih tersambung setelah ${_kDisconnectTimeout.inMilliseconds}ms', name: _logName, ); } Future> _printToNetworkPrinter( Printer printer, List printData, ) async { try { final result = await _networkClient.printTicket( host: printer.address, data: printData, ); 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); } 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, stackTrace, reason: 'Error printing ${printer.name}', information: ['Printer: ${printer.name}', 'IP: ${printer.address}'], ); log('Error printing network', name: _logName, error: e); return left( const PrinterFailure.dynamicErrorMessage('Error printing struct'), ); } } /// 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); 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; final result = await PrintBluetoothThermal.writeBytes( data.sublist(i, end), ); if (!result) return false; if (end < data.length) await Future.delayed(_kChunkDelay); } return true; } // =========================================================================== // Orkestrasi cetak // =========================================================================== @override Stream> printStations({ required Order order, required List stations, }) { final controller = StreamController>(); controller.onListen = () => _runPrintJob(order, stations, controller); return controller.stream; } Future _runPrintJob( Order order, List stations, StreamController> controller, ) async { final results = { for (final station in stations) station: PrintStationResult.pending(station), }; void publish() { if (controller.isClosed) return; controller.add([for (final station in stations) results[station]!]); } void mark(PrintStation station, PrintStationResult result) { results[station] = result; publish(); } publish(); try { // Job diserialkan supaya dua kali cetak tidak saling menyelipkan tiket. await _jobLock.synchronized(() async { final outlet = await _outletLocalDatasource.currentOutlet(); final user = await _authLocalDataProvider.currentUser(); final plans = <_StationPlan>[]; // Printer diresolusi di depan supaya kita tahu station mana yang lewat // Bluetooth dan mana yang lewat jaringan sebelum mulai mencetak. for (final station in stations) { final items = _itemsFor(order, station); if (items.isEmpty) { mark( station, results[station]!.copyWith( status: PrintStationStatus.skipped, message: 'Tidak ada item untuk station ini', ), ); continue; } final record = await _localDataProvider.findPrinterByCode( station.printerCode, ); if (!record.hasData) { FirebaseCrashlytics.instance.recordError( '${station.printerCode} printer not found', null, reason: 'Printer belum diatur di halaman pengaturan', information: [ 'Order ID: ${order.id}', 'Station: ${station.name}', ], ); mark( station, results[station]!.copyWith( status: PrintStationStatus.failed, message: 'Printer ${station.label} belum diatur di halaman pengaturan', ), ); continue; } plans.add( _StationPlan( station: station, printer: record.data!.toDomain(), order: order.copyWith(orderItems: items), ), ); } Future run(_StationPlan plan) async { final stopwatch = Stopwatch()..start(); mark( plan.station, results[plan.station]!.copyWith( status: PrintStationStatus.printing, ), ); 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( (failure) => PrintStationResult( station: plan.station, status: PrintStationStatus.failed, message: _failureMessage(failure), elapsed: stopwatch.elapsed, ), (_) => PrintStationResult( station: plan.station, status: PrintStationStatus.success, elapsed: stopwatch.elapsed, ), ), ); } final bluetooth = plans.where((plan) => plan.isBluetooth).toList(); final network = plans.where((plan) => !plan.isBluetooth).toList(); await Future.wait([ // Bluetooth berurutan — radionya cuma satu. _runSequentially(_groupedByAddress(bluetooth), run), // Jaringan paralel — tiap printer punya IP sendiri dan tidak // berebut radio dengan Bluetooth. ...network.map(run), ]); }); } catch (e, stackTrace) { FirebaseCrashlytics.instance.recordError( e, stackTrace, reason: 'Print job error', information: ['Order ID: ${order.id}'], ); log('Error running print job', name: _logName, error: e); for (final station in stations) { if (results[station]!.isFinished) continue; results[station] = results[station]!.copyWith( status: PrintStationStatus.failed, message: 'Terjadi kesalahan saat mencetak', ); } publish(); } finally { await controller.close(); } } Future> _printStation( _StationPlan plan, Outlet outlet, String cashierName, ) async { try { log('Starting to print ${plan.station.name}', name: _logName); final payload = await _buildPayload(plan, outlet, cashierName); final result = await printStruct(plan.printer, payload); log('Finished printing ${plan.station.name}', name: _logName); return result.map((_) => unit); } catch (e, stackTrace) { FirebaseCrashlytics.instance.recordError( e, stackTrace, reason: 'Print station error: ${plan.station.name}', information: [ 'Order ID: ${plan.order.id}', 'Printer: ${plan.printer.name}', ], ); log('Error printing ${plan.station.name}', name: _logName, error: e); return left( PrinterFailure.dynamicErrorMessage( 'Gagal menyiapkan cetakan ${plan.station.label}', ), ); } } Future> _buildPayload( _StationPlan plan, Outlet outlet, String cashierName, ) { final ui = PrintUi(); final order = plan.order; final paper = plan.printer.paper.toIntegerFromText; return switch (plan.station) { PrintStation.receipt => ui.printOrder( order: order, outlet: outlet, cashierName: cashierName, paper: paper, ), PrintStation.cashier => ui.printCashier( order: order, outlet: outlet, cashierName: cashierName, paper: paper, ), PrintStation.checker => ui.printChecker( order: order, outlet: outlet, cashierName: cashierName, paper: paper, ), PrintStation.kitchen => ui.printKitchen( order: order, outlet: outlet, cashierName: cashierName, paper: paper, ), PrintStation.bar => ui.printBar( order: order, outlet: outlet, cashierName: cashierName, paper: paper, ), PrintStation.voided => ui.printVoid( order: order, outlet: outlet, cashierName: cashierName, paper: paper, ), PrintStation.splitBill => ui.printSplit( order: order, outlet: outlet, cashierName: cashierName, paper: paper, ), }; } List _itemsFor(Order order, PrintStation station) { final type = station.itemPrinterType; if (type == null) return order.orderItems; return order.orderItems.where((item) => item.printerType == type).toList(); } /// Mengelompokkan station per alamat printer supaya perpindahan koneksi /// terjadi sesedikit mungkin — tiap perpindahan berarti disconnect dan /// connect ulang di radio yang sama. Urutan kemunculan alamat dipertahankan, /// jadi struk pelanggan tetap keluar lebih dulu. List<_StationPlan> _groupedByAddress(List<_StationPlan> plans) { final grouped = >{}; for (final plan in plans) { grouped.putIfAbsent(plan.printer.address, () => []).add(plan); } return [for (final group in grouped.values) ...group]; } Future _runSequentially( List<_StationPlan> plans, Future Function(_StationPlan) run, ) async { for (final plan in plans) { await run(plan); } } String _failureMessage(PrinterFailure failure) => failure.map( serverError: (_) => 'Terjadi kesalahan pada server', unexpectedError: (_) => 'Terjadi kesalahan, silahkan coba lagi', empty: (_) => 'Tidak ada data', localStorageError: (value) => value.erroMessage, dynamicErrorMessage: (value) => value.erroMessage, ); } class _StationPlan { final PrintStation station; final Printer printer; /// Order yang sudah difilter untuk station ini — kitchen dan bar hanya /// memuat item miliknya. final Order order; const _StationPlan({ required this.station, required this.printer, required this.order, }); bool get isBluetooth => printer.type == 'Bluetooth'; }