Files
apskel-pos-flutter-v2/lib/infrastructure/printer/repositories/printer_repository.dart
T
efrilmandClaude Opus 5 32f64a197a fix: pulihkan koneksi Bluetooth dan deteksi kegagalan printer jaringan
Bluetooth: percobaan ulang connect sebelumnya tidak menutup socket lebih dulu, padahal 'read failed, socket might closed or timeout' justru berarti socket RFCOMM masih setengah terbuka — ketiga percobaan gagal identik dan retry-nya sia-sia. Tiap percobaan ulang kini didahului disconnect paksa.

connectBluetooth mengembalikan sukses begitu ADA koneksi tanpa memeriksa apakah itu printer yang diminta, dan tidak pernah mencatat MAC yang aktif. Kini memakai jalur yang sama dengan mencetak.

Izin runtime Bluetooth sebelumnya hanya diminta dari dialog pilih perangkat. Karena reinstall mencabut izin, kasir yang tidak pernah membuka dialog itu gagal cetak tanpa penjelasan. Status adapter, izin, dan pairing kini ikut di log, dan printer yang tidak ter-pairing digagalkan langsung dengan pesan yang menyebut tindakannya.

Jaringan: printTicket hanya menaruh byte di socket lalu menutup koneksi, tidak pernah membaca balik, sehingga kertas habis atau cover terbuka terlihat sama dengan sukses — itu sebabnya kegagalan kitchen tidak pernah muncul di Crashlytics. NetworkPrinterClient baru menanyakan kondisi printer lewat DLE EOT sebelum dan sesudah mencetak, dengan degradasi anggun untuk printer yang tidak mendukungnya.

Panggilan disconnect() ganda dihapus; printTicket sudah flush dan close sendiri, dan panggilan kedua tanpa await membuat error-nya tercatat sebagai crash fatal palsu. Ditambah jejak per station ke Crashlytics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 14:06:22 +07:00

895 lines
29 KiB
Dart

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<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),
);
}
@override
Future<Either<PrinterFailure, bool>> 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<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(
const PrinterFailure.dynamicErrorMessage(
'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(
const PrinterFailure.dynamicErrorMessage(
'Error checking Bluetooth status',
),
);
}
}
@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());
}
}
// ===========================================================================
// 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<Either<PrinterFailure, bool>> printStruct(
Printer printer,
List<int> printData,
) async {
if (printer.type == 'Bluetooth') {
return _bluetoothLock.synchronized(
() => _printBluetooth(printer, printData),
);
}
return _printToNetworkPrinter(printer, printData);
}
Future<Either<PrinterFailure, bool>> _printBluetooth(
Printer printer,
List<int> 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<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();
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<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 {
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<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;
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<List<PrintStationResult>> printStations({
required Order order,
required List<PrintStation> stations,
}) {
final controller = StreamController<List<PrintStationResult>>();
controller.onListen = () => _runPrintJob(order, stations, controller);
return controller.stream;
}
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();
// 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<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';
}