Files
apskel-pos-flutter-v2/lib/infrastructure/printer/repositories/printer_repository.dart
T
efrilmandClaude Opus 5 1b4caf45d5 fix: cetak semua station tanpa saling membatalkan
printStruckOrder berhenti di kegagalan pertama, jadi satu station gagal membuat semua station sesudahnya tidak pernah dicoba. Karena checker (Bluetooth) berada sebelum kitchen dan bar (Network) dalam rantai, satu kegagalan koneksi Bluetooth menghilangkan tiga tiket sekaligus, dua di antaranya di jaringan yang sehat.

Jalur printer Network juga mengembalikan right(false) saat gagal, yang lolos sebagai sukses ke pemanggil, sehingga kasir melihat 'berhasil dicetak' padahal tidak ada kertas keluar.

Perubahan: repository kini mengembalikan Stream<List<PrintStationResult>> dan tetap mencoba semua station; kegagalan Network jadi left; station Network berjalan paralel dengan rantai Bluetooth; station Bluetooth dikelompokkan per alamat agar perpindahan koneksi seminimal mungkin; CapabilityProfile di-cache.

Bluetooth: retry connect dengan backoff dan disconnect di tiap percobaan ulang (socket RFCOMM setengah terbuka membuat connect berulang gagal identik), jeda settle dikembalikan setelah dipangkas ke 300ms, satu lock untuk seluruh radio (bukan per MAC), izin runtime dipastikan di jalur cetak, dan repository jadi LazySingleton agar state koneksi benar-benar dibagi.

UI: dialog progres per-station menggantikan toast tunggal, lengkap dengan durasi tiap station dan tombol cetak ulang khusus yang gagal. Enam listener duplikat di halaman diganti satu listener terpusat di AppWidget. Menu Print di detail transaksi dapat item Print All.

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

877 lines
28 KiB
Dart

import 'dart:async';
import 'dart:developer';
import 'package:dartz/dartz.dart' hide Order;
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter_esc_pos_network/flutter_esc_pos_network.dart';
import 'package:injectable/injectable.dart' hide Order;
import 'package:permission_handler/permission_handler.dart';
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
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 '../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();
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 printResult = await _printNetwork(printer.address, printData);
if (!printResult) {
FirebaseCrashlytics.instance.recordError(
'Failed to print to Network Printer',
null,
reason: 'Failed to print to Network Printer',
information: [
'function: _printNetwork(printer.address, printData)',
'Printer: ${printer.name}',
'ipAddress: ${printer.address}',
'bytes: ${printData.length}',
'in: $_logName',
],
);
// Dulu ini `right(false)`, yang lolos sebagai sukses ke pemanggil —
// kasir melihat "berhasil dicetak" padahal tidak ada kertas keluar.
return left(
PrinterFailure.dynamicErrorMessage(
'Tidak bisa mencetak ke ${printer.name} (${printer.address})',
),
);
}
return right(true);
} 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'),
);
}
}
Future<bool> _printNetwork(String ipAddress, List<int> printData) async {
try {
final printer = PrinterNetworkManager(
ipAddress,
port: 9100,
timeout: const Duration(seconds: 15),
);
final PosPrintResult connect = await printer.connect();
if (connect != PosPrintResult.success) {
log('Failed to connect to Network printer: ${connect.msg}');
return false;
}
final PosPrintResult printing = await printer.printTicket(printData);
printer.disconnect();
if (printing != PosPrintResult.success) {
log('Failed to print via Network printer: ${printing.msg}');
return false;
}
log('Successfully printed via Network printer: $ipAddress');
return true;
} catch (e, stackTrace) {
FirebaseCrashlytics.instance.recordError(
e,
stackTrace,
reason: 'Error printing via Network',
information: [
'function: _printNetwork(String ipAddress, List<int> printData)',
'ipAddress: $ipAddress',
],
);
log('Error printing via Network: $e');
return false;
}
}
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();
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';
}