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>
561 lines
16 KiB
Dart
561 lines
16 KiB
Dart
import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
|
|
|
|
import '../../../common/extension/extension.dart';
|
|
import '../../../domain/order/order.dart';
|
|
import '../../../domain/outlet/outlet.dart';
|
|
import 'receipt_component_builder.dart';
|
|
|
|
class PrintUi {
|
|
/// `CapabilityProfile.load()` membaca dan mem-parse aset tiap kali dipanggil,
|
|
/// dan sekali cetak semua station memanggilnya sampai lima kali. Hasilnya
|
|
/// sama untuk seluruh aplikasi, jadi cukup dimuat satu kali.
|
|
static Future<CapabilityProfile>? _cachedProfile;
|
|
|
|
static Future<CapabilityProfile> _capabilityProfile() async {
|
|
final cached = _cachedProfile;
|
|
if (cached != null) return cached;
|
|
|
|
final future = CapabilityProfile.load();
|
|
_cachedProfile = future;
|
|
|
|
try {
|
|
return await future;
|
|
} catch (_) {
|
|
// Jangan kunci kegagalan sesaat — biarkan cetakan berikutnya mencoba lagi.
|
|
_cachedProfile = null;
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<List<int>> printOrder({
|
|
required Order order,
|
|
required Outlet outlet,
|
|
required String cashierName,
|
|
int paper = 58,
|
|
}) async {
|
|
List<int> bytes = [];
|
|
|
|
final profile = await _capabilityProfile();
|
|
final generator = Generator(
|
|
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
|
|
profile,
|
|
);
|
|
final builder = ReceiptComponentBuilder(
|
|
generator: generator,
|
|
paperSize: paper,
|
|
);
|
|
|
|
bytes += generator.reset();
|
|
|
|
bytes += builder.header(
|
|
outletName: outlet.name,
|
|
address: outlet.address,
|
|
phoneNumber: outlet.phoneNumber,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.orderInfo(
|
|
orderNumber: order.orderNumber,
|
|
customerName: order.metadata['customer_name'] ?? '-',
|
|
cashierName: cashierName,
|
|
paymentMethod: order.payments.last.paymentMethodName,
|
|
tableNumber: order.tableNumber,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.orderType(order.orderType, fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,);
|
|
|
|
bytes += builder.emptyLines(1);
|
|
|
|
for (final item in order.orderItemNotCancelled) {
|
|
bytes += builder.orderItem(
|
|
productName: item.productName,
|
|
quantity: item.quantity,
|
|
unitPrice: item.unitPrice.currencyFormatRpV2,
|
|
totalPrice: item.totalPrice.currencyFormatRpV2,
|
|
variantName: item.productVariantName,
|
|
notes: item.notes,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
}
|
|
|
|
bytes += builder.summary(
|
|
totalItems: order.orderItems.length,
|
|
subtotal: order.subtotal.currencyFormatRpV2,
|
|
discount: order.discountAmount.currencyFormatRpV2,
|
|
total: order.totalAmount.currencyFormatRpV2,
|
|
paid: order.totalPaid.currencyFormatRpV2,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.footer();
|
|
|
|
return bytes;
|
|
}
|
|
|
|
Future<List<int>> printChecker({
|
|
required Order order,
|
|
required Outlet outlet,
|
|
required String cashierName,
|
|
int paper = 58,
|
|
}) async {
|
|
List<int> bytes = [];
|
|
|
|
final profile = await _capabilityProfile();
|
|
final generator = Generator(
|
|
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
|
|
profile,
|
|
);
|
|
final builder = ReceiptComponentBuilder(
|
|
generator: generator,
|
|
paperSize: paper,
|
|
);
|
|
|
|
bytes += generator.reset();
|
|
|
|
// Header
|
|
bytes += builder.textCenter('Table Checker', bold: true);
|
|
bytes += builder.tableName(order.tableNumber.isNotEmpty ? order.tableNumber : '-', height: PosTextSize.size3, width: PosTextSize.size3);
|
|
|
|
// Order info — label : value, left aligned
|
|
bytes += builder.orderInfoSimple(
|
|
orderNumber: order.orderNumber,
|
|
orderType: order.orderType,
|
|
cashierName: cashierName,
|
|
customerName: order.metadata['customer_name'] ?? '-',
|
|
);
|
|
|
|
bytes += builder.separator();
|
|
|
|
// Items — qty NAMA (uppercase), variant indent
|
|
for (final item in order.orderItems.where((e) => e.printToChecker)) {
|
|
final name = item.productName.toUpperCase();
|
|
bytes += builder.itemText('${item.quantity} $name');
|
|
if (item.productVariantName.isNotEmpty) {
|
|
bytes += builder.itemText(' ${item.productVariantName.toUpperCase()}', bold: false);
|
|
}
|
|
if (item.notes.isNotEmpty) {
|
|
bytes += builder.itemText(' *${item.notes}', bold: false);
|
|
}
|
|
}
|
|
|
|
bytes += builder.separator();
|
|
bytes += builder.cutOnly();
|
|
|
|
return bytes;
|
|
}
|
|
|
|
Future<List<int>> printKitchen({
|
|
required Order order,
|
|
required Outlet outlet,
|
|
required String cashierName,
|
|
int paper = 58,
|
|
}) async {
|
|
List<int> bytes = [];
|
|
|
|
final profile = await _capabilityProfile();
|
|
final generator = Generator(
|
|
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
|
|
profile,
|
|
);
|
|
final builder = ReceiptComponentBuilder(
|
|
generator: generator,
|
|
paperSize: paper,
|
|
);
|
|
|
|
for (final item in order.orderItems) {
|
|
bytes += generator.reset();
|
|
|
|
// Header
|
|
bytes += builder.row2Columns('Kitchen', order.orderType.toUpperCase(), bold: true);
|
|
bytes += builder.separator();
|
|
bytes += builder.textCenter(
|
|
'Table : ${order.tableNumber.isNotEmpty ? order.tableNumber : '-'}',
|
|
bold: true,
|
|
);
|
|
bytes += builder.separator();
|
|
|
|
// Order info
|
|
bytes += builder.orderInfoSimple(
|
|
orderNumber: order.orderNumber,
|
|
cashierName: cashierName,
|
|
customerName: order.metadata['customer_name'] ?? '-',
|
|
);
|
|
|
|
bytes += builder.separator();
|
|
|
|
// Single item
|
|
final name = item.productName.toUpperCase();
|
|
bytes += builder.itemText('${item.quantity} $name');
|
|
if (item.productVariantName.isNotEmpty) {
|
|
bytes += builder.itemText(' ${item.productVariantName.toUpperCase()}', bold: false);
|
|
}
|
|
if (item.notes.isNotEmpty) {
|
|
bytes += builder.itemText(' *${item.notes}', bold: false);
|
|
}
|
|
|
|
bytes += builder.separator();
|
|
bytes += builder.cutOnly();
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
Future<List<int>> printBar({
|
|
required Order order,
|
|
required Outlet outlet,
|
|
required String cashierName,
|
|
int paper = 58,
|
|
}) async {
|
|
List<int> bytes = [];
|
|
|
|
final profile = await _capabilityProfile();
|
|
final generator = Generator(
|
|
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
|
|
profile,
|
|
);
|
|
final builder = ReceiptComponentBuilder(
|
|
generator: generator,
|
|
paperSize: paper,
|
|
);
|
|
|
|
for (final item in order.orderItems) {
|
|
bytes += generator.reset();
|
|
|
|
// Header
|
|
bytes += builder.row2Columns('Bar', order.orderType.toUpperCase(), bold: true);
|
|
bytes += builder.separator();
|
|
bytes += builder.textCenter(
|
|
'Table : ${order.tableNumber.isNotEmpty ? order.tableNumber : '-'}',
|
|
bold: true,
|
|
);
|
|
bytes += builder.separator();
|
|
|
|
// Order info
|
|
bytes += builder.orderInfoSimple(
|
|
orderNumber: order.orderNumber,
|
|
customerName: order.metadata['customer_name'] ?? '-',
|
|
cashierName: cashierName,
|
|
);
|
|
|
|
bytes += builder.separator();
|
|
|
|
// Single item
|
|
final name = item.productName.toUpperCase();
|
|
bytes += builder.itemText('${item.quantity} $name');
|
|
if (item.productVariantName.isNotEmpty) {
|
|
bytes += builder.itemText(' ${item.productVariantName.toUpperCase()}', bold: false);
|
|
}
|
|
if (item.notes.isNotEmpty) {
|
|
bytes += builder.itemText(' *${item.notes}', bold: false);
|
|
}
|
|
|
|
bytes += builder.separator();
|
|
bytes += builder.cutOnly();
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
Future<List<int>> printTicket({
|
|
required Order order,
|
|
required Outlet outlet,
|
|
int paper = 58,
|
|
}) async {
|
|
List<int> bytes = [];
|
|
|
|
final profile = await _capabilityProfile();
|
|
final generator = Generator(
|
|
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
|
|
profile,
|
|
);
|
|
final builder = ReceiptComponentBuilder(
|
|
generator: generator,
|
|
paperSize: paper,
|
|
);
|
|
|
|
bytes += generator.reset();
|
|
|
|
bytes += builder.header(
|
|
outletName: outlet.name,
|
|
address: outlet.address,
|
|
phoneNumber: outlet.phoneNumber,
|
|
);
|
|
|
|
bytes += builder.dateTime(DateTime.now());
|
|
bytes += builder.row2Columns('Code No.', order.orderNumber);
|
|
bytes += builder.separator();
|
|
|
|
bytes += builder.emptyLines(2);
|
|
bytes += builder.qrCode('1267128127182182');
|
|
bytes += builder.emptyLines(1);
|
|
bytes += builder.textCenter('1267128127182182');
|
|
bytes += builder.emptyLines(2);
|
|
|
|
bytes += builder.footer();
|
|
|
|
return bytes;
|
|
}
|
|
|
|
Future<List<int>> printCashier({
|
|
required Order order,
|
|
required Outlet outlet,
|
|
required String cashierName,
|
|
int paper = 58,
|
|
}) async {
|
|
List<int> bytes = [];
|
|
|
|
final profile = await _capabilityProfile();
|
|
final generator = Generator(
|
|
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
|
|
profile,
|
|
);
|
|
final builder = ReceiptComponentBuilder(
|
|
generator: generator,
|
|
paperSize: paper,
|
|
);
|
|
|
|
bytes += generator.reset();
|
|
|
|
bytes += builder.header(
|
|
outletName: outlet.name,
|
|
address: outlet.address,
|
|
phoneNumber: outlet.phoneNumber,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.orderInfo(
|
|
orderNumber: order.orderNumber,
|
|
customerName: order.metadata['customer_name'] ?? '-',
|
|
cashierName: cashierName,
|
|
paymentMethod: order.payments.isEmpty
|
|
? null
|
|
: order.payments.last.paymentMethodName,
|
|
tableNumber: order.tableNumber,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.orderType(order.orderType, fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,);
|
|
|
|
bytes += builder.emptyLines(1);
|
|
|
|
for (final item in order.orderItemNotCancelled) {
|
|
bytes += builder.orderItem(
|
|
productName: item.productName,
|
|
quantity: item.quantity,
|
|
unitPrice: item.unitPrice.currencyFormatRpV2,
|
|
totalPrice: item.totalPrice.currencyFormatRpV2,
|
|
variantName: item.productVariantName,
|
|
notes: item.notes,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
}
|
|
|
|
bytes += builder.summary(
|
|
totalItems: order.orderItems.length,
|
|
subtotal: order.subtotal.currencyFormatRpV2,
|
|
discount: order.discountAmount.currencyFormatRpV2,
|
|
total: order.totalAmount.currencyFormatRpV2,
|
|
paid: order.totalPaid.currencyFormatRpV2,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.footer(message: 'Kasir');
|
|
|
|
return bytes;
|
|
}
|
|
|
|
Future<List<int>> printVoid({
|
|
required Order order,
|
|
required Outlet outlet,
|
|
required String cashierName,
|
|
int paper = 58,
|
|
}) async {
|
|
List<int> bytes = [];
|
|
|
|
final profile = await _capabilityProfile();
|
|
final generator = Generator(
|
|
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
|
|
profile,
|
|
);
|
|
final builder = ReceiptComponentBuilder(
|
|
generator: generator,
|
|
paperSize: paper,
|
|
);
|
|
|
|
bytes += generator.reset();
|
|
|
|
bytes += builder.header(
|
|
outletName: outlet.name,
|
|
address: outlet.address,
|
|
phoneNumber: outlet.phoneNumber,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.dateTime(DateTime.now());
|
|
|
|
bytes += builder.orderInfo(
|
|
orderNumber: order.orderNumber,
|
|
customerName: order.metadata['customer_name'] ?? '-',
|
|
cashierName: cashierName,
|
|
paymentMethod: order.payments.isEmpty
|
|
? null
|
|
: order.payments.last.paymentMethodName,
|
|
tableNumber: order.tableNumber,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.orderType('Void', fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,);
|
|
|
|
bytes += builder.emptyLines(1);
|
|
|
|
for (final item in order.orderItems) {
|
|
bytes += builder.orderItem(
|
|
productName: item.productName,
|
|
quantity: item.quantity,
|
|
unitPrice: item.unitPrice.currencyFormatRpV2,
|
|
totalPrice: item.totalPrice.currencyFormatRpV2,
|
|
variantName: item.productVariantName,
|
|
notes: item.notes,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
}
|
|
bytes += builder.summary(
|
|
totalItems: order.orderItems.length,
|
|
subtotal: order.subtotal.currencyFormatRpV2,
|
|
discount: order.discountAmount.currencyFormatRpV2,
|
|
total: order.totalAmount.currencyFormatRpV2,
|
|
paid: order.totalPaid.currencyFormatRpV2,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.footer(message: 'Kasir');
|
|
|
|
return bytes;
|
|
}
|
|
|
|
Future<List<int>> printSplit({
|
|
required Order order,
|
|
required Outlet outlet,
|
|
required String cashierName,
|
|
int paper = 58,
|
|
}) async {
|
|
List<int> bytes = [];
|
|
|
|
final profile = await _capabilityProfile();
|
|
final generator = Generator(
|
|
paper == 58 ? PaperSize.mm58 : PaperSize.mm80,
|
|
profile,
|
|
);
|
|
final builder = ReceiptComponentBuilder(
|
|
generator: generator,
|
|
paperSize: paper,
|
|
);
|
|
|
|
bytes += generator.reset();
|
|
|
|
bytes += builder.header(
|
|
outletName: outlet.name,
|
|
address: outlet.address,
|
|
phoneNumber: outlet.phoneNumber,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.dateTime(DateTime.now(), fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,);
|
|
|
|
bytes += builder.orderInfo(
|
|
orderNumber: order.orderNumber,
|
|
customerName: order.metadata['customer_name'] ?? '-',
|
|
cashierName: cashierName,
|
|
paymentMethod: order.payments.isEmpty
|
|
? null
|
|
: order.payments.last.paymentMethodName,
|
|
tableNumber: order.tableNumber,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.orderType('Split', fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,);
|
|
|
|
bytes += builder.row2Columns(
|
|
'Split',
|
|
'${order.payments.last.splitNumber} / ${order.payments.last.splitTotal}',
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.separator();
|
|
|
|
bytes += builder.emptyLines(1);
|
|
|
|
for (final item in order.orderItems) {
|
|
bytes += builder.orderItem(
|
|
productName: item.productName,
|
|
quantity: item.quantity,
|
|
unitPrice: item.unitPrice.currencyFormatRpV2,
|
|
totalPrice: item.totalPrice.currencyFormatRpV2,
|
|
variantName: item.productVariantName,
|
|
notes: item.notes,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
}
|
|
|
|
bytes += builder.emptyLines(1);
|
|
|
|
bytes += builder.summary(
|
|
totalItems: 0,
|
|
subtotal: order.payments.last.amount.currencyFormatRpV2,
|
|
discount: 0.currencyFormatRpV2,
|
|
total: order.payments.last.amount.currencyFormatRpV2,
|
|
paid: order.payments.last.amount.currencyFormatRpV2,
|
|
fontType: PosFontType.fontA,
|
|
width: PosTextSize.size1,
|
|
height: PosTextSize.size1,
|
|
);
|
|
|
|
bytes += builder.footer(message: 'Terima Kasih');
|
|
|
|
return bytes;
|
|
}
|
|
}
|