2026-09-02 20:44:14 +07:00
import 'dart:async' ;
2025-11-04 14:58:51 +07:00
import 'dart:developer' ;
2025-11-07 16:29:26 +07:00
import 'package:dartz/dartz.dart' hide Order ;
2025-11-04 14:58:51 +07:00
import 'package:firebase_crashlytics/firebase_crashlytics.dart' ;
2025-11-07 16:29:26 +07:00
import 'package:injectable/injectable.dart' hide Order ;
2026-09-02 20:44:14 +07:00
import 'package:permission_handler/permission_handler.dart' ;
2025-11-04 14:58:51 +07:00
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart' ;
2026-03-01 19:37:23 +07:00
import 'package:synchronized/synchronized.dart' ;
2025-11-04 14:58:51 +07:00
2026-03-03 19:11:29 +07:00
import '../../../common/extension/extension.dart' ;
2025-11-07 16:29:26 +07:00
import '../../../domain/order/order.dart' ;
import '../../../domain/outlet/outlet.dart' ;
2025-11-04 14:58:51 +07:00
import '../../../domain/printer/printer.dart' ;
2025-11-07 16:29:26 +07:00
import '../../../presentation/components/print/print_ui.dart' ;
import '../../auth/datasources/local_data_provider.dart' ;
import '../../outlet/datasources/local_data_provider.dart' ;
2025-11-04 22:13:15 +07:00
import '../datasource/local_data_provider.dart' ;
2026-09-05 14:06:22 +07:00
import '../datasource/network_printer_client.dart' ;
2025-11-04 22:13:15 +07:00
import '../printer_dtos.dart' ;
2025-11-04 14:58:51 +07:00
2026-09-02 20:44:14 +07:00
@ LazySingleton ( as: IPrinterRepository )
2025-11-04 14:58:51 +07:00
class PrinterRepository implements IPrinterRepository {
2025-11-04 22:13:15 +07:00
final PrinterLocalDataProvider _localDataProvider ;
2025-11-07 16:29:26 +07:00
final OutletLocalDatasource _outletLocalDatasource ;
final AuthLocalDataProvider _authLocalDataProvider ;
2025-11-04 14:58:51 +07:00
final _logName = 'PrinterRepository' ;
2025-11-07 16:29:26 +07:00
PrinterRepository (
this . _localDataProvider ,
this . _outletLocalDatasource ,
this . _authLocalDataProvider ,
);
2025-11-04 14:58:51 +07:00
2026-09-02 20:44:14 +07:00
/// 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 ();
2026-09-05 14:06:22 +07:00
/// 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 ();
2026-03-01 19:37:23 +07:00
String ? _connectedMac ;
2025-11-04 14:58:51 +07:00
@ override
2026-09-02 20:44:14 +07:00
Future < Either < PrinterFailure , bool >> 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 ),
);
2025-11-04 14:58:51 +07:00
}
@ override
Future < Either < PrinterFailure , bool >> disconectBluetooth () async {
try {
bool result = await PrintBluetoothThermal . disconnect ;
2026-09-02 20:44:14 +07:00
_connectedMac = null ;
2025-11-04 14:58:51 +07:00
log ( "Bluetooth printer disconnected: $ result " , name: _logName );
return right ( result );
} catch ( e ) {
2026-09-02 20:44:14 +07:00
_connectedMac = null ;
2025-11-04 14:58:51 +07:00
log ( "Error disconnecting Bluetooth printer" , error: e , name: _logName );
return left (
2026-03-01 20:02:12 +07:00
const PrinterFailure . dynamicErrorMessage (
2025-11-04 14:58:51 +07:00
'Error disconnecting Bluetooth printer' ,
),
);
}
}
@ override
Future < Either < PrinterFailure , List < BluetoothInfo >>>
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 (
2026-03-01 20:02:12 +07:00
const PrinterFailure . dynamicErrorMessage (
2025-11-04 14:58:51 +07:00
'Error getting paired Bluetooth devices' ,
),
);
}
}
@ override
Future < Either < PrinterFailure , bool >> isBluetoothEnabled () async {
try {
final result = await PrintBluetoothThermal . bluetoothEnabled ;
return right ( result );
} catch ( e ) {
log ( "Error checking Bluetooth status" , name: _logName , error: e );
return left (
2026-03-01 20:02:12 +07:00
const PrinterFailure . dynamicErrorMessage (
'Error checking Bluetooth status' ,
),
2025-11-04 14:58:51 +07:00
);
}
}
2025-11-04 22:13:15 +07:00
@ override
Future < Either < PrinterFailure , Unit >> 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 < Either < PrinterFailure , Unit >> 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 < Either < PrinterFailure , Printer >> 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 < Either < PrinterFailure , Unit >> 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 ());
}
}
2025-11-06 18:15:54 +07:00
2026-09-02 20:44:14 +07:00
// ===========================================================================
// 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 );
2025-11-06 18:15:54 +07:00
@ override
Future < Either < PrinterFailure , bool >> printStruct (
Printer printer ,
List < int > printData ,
) async {
2026-03-01 19:37:23 +07:00
if ( printer . type == 'Bluetooth' ) {
2026-09-02 20:44:14 +07:00
return _bluetoothLock . synchronized (
() => _printBluetooth ( printer , printData ),
);
}
return _printToNetworkPrinter ( printer , printData );
}
2025-11-06 18:15:54 +07:00
2026-09-02 20:44:14 +07:00
Future < Either < PrinterFailure , bool >> _printBluetooth (
Printer printer ,
List < int > printData ,
) async {
try {
final connected = await _ensureBluetoothConnected ( printer . address );
if ( connected . isLeft ()) return connected ;
2026-03-01 19:37:23 +07:00
2026-09-02 20:44:14 +07:00
log (
'Printer connected to bluetooth: ${ printer . name } , ${ printer . address } ' ,
name: _logName ,
);
2026-03-01 19:37:23 +07:00
2026-09-02 20:44:14 +07:00
final printResult = await _writeBytesChunked ( printData );
log ( 'Print result ${ printer . name } : $ printResult ' , name: _logName );
2025-11-06 18:15:54 +07:00
2026-09-02 20:44:14 +07:00
if ( ! printResult ) {
// Tulis yang gagal di tengah jalan menyisakan koneksi dengan status
// tidak jelas — paksa reconnect pada cetakan berikutnya.
_connectedMac = null ;
2025-11-06 18:15:54 +07:00
FirebaseCrashlytics . instance . recordError (
2026-09-02 20:44:14 +07:00
'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 ' ,
],
2026-03-01 19:37:23 +07:00
);
return left (
2026-09-02 20:44:14 +07:00
PrinterFailure . dynamicErrorMessage (
'Gagal mengirim data ke ${ printer . name } ' ,
),
2025-11-06 18:15:54 +07:00
);
}
2026-09-02 20:44:14 +07:00
// 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' ),
);
2025-11-06 18:15:54 +07:00
}
}
2026-03-01 19:37:23 +07:00
2026-09-02 20:44:14 +07:00
/// Memastikan radio tersambung ke [macAddress], reconnect kalau perlu.
Future < Either < PrinterFailure , bool >> _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 ();
2026-03-01 19:37:23 +07:00
2026-09-02 20:44:14 +07:00
final isConnected = await PrintBluetoothThermal . connectionStatus ;
final isAdapterOn = await _isBluetoothOn ();
final isPaired = await _isPaired ( macAddress );
2026-03-01 19:37:23 +07:00
2026-09-02 20:44:14 +07:00
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 < bool > _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 < bool > _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 < bool > _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 < void > _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 < void > _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 < Either < PrinterFailure , bool >> _printToNetworkPrinter (
Printer printer ,
List < int > printData ,
) async {
try {
2026-09-05 14:06:22 +07:00
final result = await _networkClient . printTicket (
host: printer . address ,
data: printData ,
);
2026-09-02 20:44:14 +07:00
2026-09-05 14:06:22 +07:00
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 ,
2026-09-02 20:44:14 +07:00
);
2026-09-05 14:06:22 +07:00
return right ( true );
2026-09-02 20:44:14 +07:00
}
2026-09-05 14:06:22 +07:00
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 ),
),
);
2026-09-02 20:44:14 +07:00
} 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' ),
);
}
}
2025-11-06 18:15:54 +07:00
2026-09-05 14:06:22 +07:00
/// 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 );
2025-11-06 18:15:54 +07:00
2026-09-05 14:06:22 +07:00
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.' ,
};
2025-11-06 18:15:54 +07:00
}
2025-11-07 16:29:26 +07:00
2026-09-05 14:06:22 +07:00
/// 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 } )' ;
}
2026-09-02 20:44:14 +07:00
Future < bool > _writeBytesChunked ( List < int > data ) async {
for ( int i = 0 ; i < data . length ; i += _kChunkSize ) {
final end = i + _kChunkSize > data . length ? data . length : i + _kChunkSize ;
2025-11-07 16:29:26 +07:00
2026-09-02 20:44:14 +07:00
final result = await PrintBluetoothThermal . writeBytes (
data . sublist ( i , end ),
2026-07-22 14:56:38 +07:00
);
2026-03-01 19:37:23 +07:00
if ( ! result ) return false ;
2026-09-02 20:44:14 +07:00
if ( end < data . length ) await Future . delayed ( _kChunkDelay );
2026-03-01 19:37:23 +07:00
}
return true ;
}
2026-04-03 17:44:04 +07:00
2026-09-02 20:44:14 +07:00
// ===========================================================================
// Orkestrasi cetak
// ===========================================================================
2026-04-03 17:44:04 +07:00
@ override
2026-09-02 20:44:14 +07:00
Stream < List < PrintStationResult >> printStations ({
2026-04-03 17:44:04 +07:00
required Order order ,
2026-09-02 20:44:14 +07:00
required List < PrintStation > stations ,
}) {
final controller = StreamController < List < PrintStationResult >> ();
controller . onListen = () => _runPrintJob ( order , stations , controller );
return controller . stream ;
2026-04-03 17:44:04 +07:00
}
2026-09-02 20:44:14 +07:00
Future < void > _runPrintJob (
Order order ,
List < PrintStation > stations ,
StreamController < List < PrintStationResult >> controller ,
) async {
final results = < PrintStation , PrintStationResult > {
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 < void > 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 ();
2026-09-05 14:06:22 +07:00
// 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' ,
);
2026-09-02 20:44:14 +07:00
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 ();
}
2026-04-03 17:44:04 +07:00
}
2026-09-02 20:44:14 +07:00
Future < Either < PrinterFailure , Unit >> _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 < List < int >> _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 < OrderItem > _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 = < String , List < _StationPlan >> {};
for ( final plan in plans ) {
grouped . putIfAbsent ( plan . printer . address , () => []). add ( plan );
}
return [ for ( final group in grouped . values ) ... group ];
}
Future < void > _runSequentially (
List < _StationPlan > plans ,
Future < void > 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' ;
2025-11-04 14:58:51 +07:00
}