Files

895 lines
29 KiB
Dart
Raw Permalink Normal View History

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;
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';
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
@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
/// 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();
2026-03-01 19:37:23 +07:00
String? _connectedMac;
2025-11-04 14:58:51 +07:00
@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),
);
2025-11-04 14:58:51 +07:00
}
@override
Future<Either<PrinterFailure, bool>> disconectBluetooth() async {
try {
bool result = await PrintBluetoothThermal.disconnect;
_connectedMac = null;
2025-11-04 14:58:51 +07:00
log("Bluetooth printer disconnected: $result", name: _logName);
return right(result);
} catch (e) {
_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
// ===========================================================================
// 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') {
return _bluetoothLock.synchronized(
() => _printBluetooth(printer, printData),
);
}
return _printToNetworkPrinter(printer, printData);
}
2025-11-06 18:15:54 +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
log(
'Printer connected to bluetooth: ${printer.name}, ${printer.address}',
name: _logName,
);
2026-03-01 19:37:23 +07:00
final printResult = await _writeBytesChunked(printData);
log('Print result ${printer.name}: $printResult', name: _logName);
2025-11-06 18:15:54 +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(
'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(
PrinterFailure.dynamicErrorMessage(
'Gagal mengirim data ke ${printer.name}',
),
2025-11-06 18:15:54 +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
/// 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
final isConnected = await PrintBluetoothThermal.connectionStatus;
final isAdapterOn = await _isBluetoothOn();
final isPaired = await _isPaired(macAddress);
2026-03-01 19:37:23 +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 {
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'),
);
}
}
2025-11-06 18:15:54 +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
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
/// 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;
2025-11-07 16:29:26 +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;
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
// ===========================================================================
// Orkestrasi cetak
// ===========================================================================
2026-04-03 17:44:04 +07:00
@override
Stream<List<PrintStationResult>> printStations({
2026-04-03 17:44:04 +07:00
required Order order,
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
}
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();
}
2026-04-03 17:44:04 +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
}