462 lines
15 KiB
Dart
462 lines
15 KiB
Dart
import 'package:flutter/services.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:cashumit/models/receipt_item.dart';
|
|
import 'package:cashumit/models/receipt_group.dart';
|
|
import 'package:flutter_esc_pos_utils/flutter_esc_pos_utils.dart';
|
|
import 'package:image/image.dart' as img;
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:cashumit/services/print_config.dart';
|
|
|
|
/// Data class untuk konfigurasi toko
|
|
class StoreConfig {
|
|
final String name;
|
|
final String address;
|
|
final String adminName;
|
|
final String adminPhone;
|
|
final String? logoPath;
|
|
final String disclaimer;
|
|
final String thankYouText;
|
|
final String pantunText;
|
|
|
|
StoreConfig({
|
|
required this.name,
|
|
required this.address,
|
|
required this.adminName,
|
|
required this.adminPhone,
|
|
this.logoPath,
|
|
required this.disclaimer,
|
|
required this.thankYouText,
|
|
required this.pantunText,
|
|
});
|
|
|
|
static Future<StoreConfig> fromSharedPreferences() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
// Baca semua nilai sekaligus untuk menghindari multiple disk reads
|
|
final storeName = prefs.getString('store_name') ?? 'TOKO SEMBAKO MURAH';
|
|
final storeAddress =
|
|
prefs.getString('store_address') ?? 'Jl. Merdeka No. 123';
|
|
final adminName = prefs.getString('admin_name') ?? 'Budi Santoso';
|
|
final adminPhone = prefs.getString('admin_phone') ?? '08123456789';
|
|
final logoPath = prefs.getString('store_logo_path');
|
|
final disclaimer = prefs.getString('store_disclaimer_text') ??
|
|
'Barang yang sudah dibeli tidak dapat dikembalikan/ditukar. '
|
|
'Harap periksa kembali struk belanja Anda sebelum meninggalkan toko.';
|
|
final thankYouText =
|
|
prefs.getString('thank_you_text') ?? '*** TERIMA KASIH ***';
|
|
final pantunText = prefs.getString('pantun_text') ??
|
|
'Belanja di toko kami, hemat dan nyaman\n'
|
|
'Dengan penuh semangat, kami siap melayani\n'
|
|
'Harapan kami, Anda selalu puas\n'
|
|
'Sampai jumpa lagi, selamat tinggal.';
|
|
|
|
return StoreConfig(
|
|
name: storeName,
|
|
address: storeAddress,
|
|
adminName: adminName,
|
|
adminPhone: adminPhone,
|
|
logoPath: logoPath,
|
|
disclaimer: disclaimer,
|
|
thankYouText: thankYouText,
|
|
pantunText: pantunText,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Data class untuk informasi pembayaran
|
|
class PaymentInfo {
|
|
final double paymentAmount;
|
|
final bool isTip;
|
|
|
|
PaymentInfo({
|
|
this.paymentAmount = 0.0,
|
|
this.isTip = false,
|
|
});
|
|
}
|
|
|
|
/// Fungsi top-level untuk decode + resize logo di isolate terpisah.
|
|
/// Mengembalikan byte PNG hasil resize (isolate-safe, tidak mengirim objek Image).
|
|
Future<Uint8List?> _decodeAndResizeLogo(String path) async {
|
|
final file = File(path);
|
|
if (!await file.exists()) return null;
|
|
final bytes = await file.readAsBytes();
|
|
final decoded = img.decodeImage(bytes);
|
|
if (decoded == null) return null;
|
|
final resized = img.copyResize(decoded, width: 300);
|
|
// Encode ke PNG agar bisa diteruskan keluar isolate
|
|
return Uint8List.fromList(img.encodePng(resized));
|
|
}
|
|
|
|
/// Service untuk menghasilkan perintah ESC/POS menggunakan flutter_esc_pos_utils
|
|
class EscPosPrintService {
|
|
/// Menghasilkan struk dalam format byte array berdasarkan data transaksi
|
|
static Future<List<int>> generateEscPosBytes({
|
|
required List<ReceiptGroup> groups,
|
|
required DateTime transactionDate,
|
|
String? destinationAccountName,
|
|
PaymentInfo? paymentInfo,
|
|
String? tipSourceAccountName,
|
|
}) async {
|
|
// Load store info from shared preferences once
|
|
final storeConfig = await StoreConfig.fromSharedPreferences();
|
|
|
|
// Format tanggal
|
|
final dateFormatter = DateFormat('dd/MM/yyyy HH:mm');
|
|
final formattedDate = dateFormatter.format(transactionDate);
|
|
|
|
// Load capability profile
|
|
final profile = await _loadCapabilityProfile();
|
|
|
|
// Get printer configuration
|
|
final config = await PrintConfig.loadPrinterConfig();
|
|
final paperSize = PrintConfig.getPaperSizeEnum(config['paperSize']);
|
|
|
|
// Use configured paper size or default
|
|
final generator = Generator(
|
|
paperSize == 'mm80' ? PaperSize.mm80 : PaperSize.mm58, profile);
|
|
|
|
// Generate all receipt sections
|
|
List<int> bytes = [];
|
|
|
|
bytes.addAll(generator.reset());
|
|
bytes.addAll([0x1D, 0x21, 0x00]); // GS ! 0 = paksa ukuran karakter normal
|
|
|
|
// Add store logo if available (diproses di isolate terpisah)
|
|
bytes.addAll(await _addStoreLogo(generator, storeConfig.logoPath));
|
|
|
|
// Add store header
|
|
bytes.addAll(_addStoreHeader(generator, storeConfig, formattedDate));
|
|
|
|
final groupsWithItems = groups.where((g) => g.items.isNotEmpty).toList();
|
|
|
|
// Cetak setiap grup
|
|
for (int gi = 0; gi < groupsWithItems.length; gi++) {
|
|
final group = groupsWithItems[gi];
|
|
|
|
// Header grup: satu garis + nama sumber (tanpa "Dari:")
|
|
bytes.addAll(generator.text('--------------------------------',
|
|
styles: PosStyles(align: PosAlign.center)));
|
|
bytes.addAll(generator.text(group.sourceAccountName ?? '(belum dipilih)',
|
|
styles: PosStyles(align: PosAlign.center, bold: true)));
|
|
|
|
// Item list grup
|
|
bytes.addAll(_addItemList(generator, group.items));
|
|
|
|
// Subtotal grup
|
|
bytes.addAll(generator.text(
|
|
_padRow('Subtotal', _formatRupiah(group.total)),
|
|
styles: PosStyles(align: PosAlign.left, bold: true),
|
|
));
|
|
|
|
if (gi < groupsWithItems.length - 1) {
|
|
bytes.addAll(generator.feed(1));
|
|
}
|
|
}
|
|
|
|
// Add grand totals + payment
|
|
bytes.addAll(_addTotals(generator, groupsWithItems, paymentInfo,
|
|
tipSourceAccountName: tipSourceAccountName));
|
|
|
|
// Add footer information
|
|
bytes.addAll(_addFooter(generator, storeConfig));
|
|
|
|
// Add cut command
|
|
bytes.addAll(generator.feed(2));
|
|
bytes.addAll(generator.cut());
|
|
|
|
return bytes;
|
|
}
|
|
|
|
/// Load capability profile safely
|
|
static Future<CapabilityProfile> _loadCapabilityProfile() async {
|
|
try {
|
|
return await CapabilityProfile.load();
|
|
} catch (e) {
|
|
// Fallback: profile default pakai nama generic
|
|
try {
|
|
return await CapabilityProfile.load();
|
|
} catch (_) {
|
|
// Jika masih gagal, lempar agar caller tahu
|
|
rethrow;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Add store logo to receipt (image processing di isolate terpisah)
|
|
static Future<List<int>> _addStoreLogo(
|
|
Generator generator, String? logoPath) async {
|
|
List<int> bytes = [];
|
|
if (logoPath != null && logoPath.isNotEmpty) {
|
|
try {
|
|
// Decode + resize di isolate terpisah agar tidak memblok UI thread
|
|
final resizedBytes = await compute(_decodeAndResizeLogo, logoPath);
|
|
if (resizedBytes != null) {
|
|
final logo = img.decodeImage(resizedBytes);
|
|
if (logo != null) {
|
|
bytes.addAll(generator.image(logo, align: PosAlign.center));
|
|
bytes.addAll(generator.feed(1));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('Error loading or processing store logo: $e');
|
|
}
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
/// Add store header information
|
|
static List<int> _addStoreHeader(
|
|
Generator generator, StoreConfig config, String formattedDate) {
|
|
List<int> bytes = [];
|
|
|
|
// Add store name as header
|
|
bytes.addAll(generator.text(config.name,
|
|
styles: PosStyles(
|
|
bold: true,
|
|
height: PosTextSize.size1,
|
|
width: PosTextSize.size1,
|
|
align: PosAlign.center,
|
|
)));
|
|
|
|
bytes.addAll(generator.text(config.address,
|
|
styles: PosStyles(align: PosAlign.center)));
|
|
bytes.addAll(generator.text('Admin: ${config.adminName}',
|
|
styles: PosStyles(align: PosAlign.center)));
|
|
bytes.addAll(generator.text('Telp: ${config.adminPhone}',
|
|
styles: PosStyles(align: PosAlign.center)));
|
|
bytes.addAll(generator.text(formattedDate,
|
|
styles: PosStyles(align: PosAlign.center)));
|
|
|
|
bytes.addAll(generator.feed(1));
|
|
|
|
return bytes;
|
|
}
|
|
|
|
/// Add item list to receipt
|
|
static List<int> _addItemList(Generator generator, List<ReceiptItem> items) {
|
|
List<int> bytes = [];
|
|
|
|
for (int i = 0; i < items.length; i++) {
|
|
try {
|
|
var item = items[i];
|
|
|
|
bytes.addAll(generator.row([
|
|
PosColumn(
|
|
text: item.description,
|
|
width: 12,
|
|
styles: PosStyles(align: PosAlign.left),
|
|
),
|
|
]));
|
|
bytes.addAll(generator.text(
|
|
_padRow(_compactQtyPrice(item.quantity, item.price), _formatRupiahCompact(item.total)),
|
|
styles: PosStyles(align: PosAlign.left),
|
|
));
|
|
} catch (e) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
/// Add totals and payment information
|
|
static List<int> _addTotals(
|
|
Generator generator, List<ReceiptGroup> groups, PaymentInfo? paymentInfo,
|
|
{String? tipSourceAccountName}) {
|
|
List<int> bytes = [];
|
|
|
|
// Separator before total
|
|
bytes.addAll(generator.text('================================',
|
|
styles: PosStyles(align: PosAlign.center)));
|
|
|
|
// Calculate grand total
|
|
double totalAmount = groups.fold(0.0, (sum, group) => sum + group.total);
|
|
|
|
// Add total row
|
|
bytes.addAll(generator.text(
|
|
_padRow('TOTAL', _formatRupiah(totalAmount)),
|
|
styles: PosStyles(bold: true, align: PosAlign.left),
|
|
));
|
|
|
|
// Add payment information if provided
|
|
if (paymentInfo != null && paymentInfo.paymentAmount > 0) {
|
|
bytes.addAll(_addPaymentInfo(generator, totalAmount, paymentInfo,
|
|
tipSourceAccountName: tipSourceAccountName));
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
/// Add payment information including change and tip
|
|
static List<int> _addPaymentInfo(
|
|
Generator generator, double totalAmount, PaymentInfo paymentInfo,
|
|
{String? tipSourceAccountName}) {
|
|
List<int> bytes = [];
|
|
final changeAmount = paymentInfo.paymentAmount - totalAmount;
|
|
|
|
// Payment amount row
|
|
bytes.addAll(generator.text(
|
|
_padRow('BAYAR', _formatRupiah(paymentInfo.paymentAmount)),
|
|
styles: PosStyles(align: PosAlign.left),
|
|
));
|
|
|
|
if (changeAmount >= 0) {
|
|
bytes.addAll(generator.text(
|
|
_padRow(paymentInfo.isTip ? 'TIP' : 'KEMBALI', _formatRupiah(changeAmount)),
|
|
styles: PosStyles(align: PosAlign.left),
|
|
));
|
|
} else {
|
|
// Amount still needed row
|
|
bytes.addAll(generator.text(
|
|
_padRow('KURANG', _formatRupiah(changeAmount.abs())),
|
|
styles: PosStyles(align: PosAlign.left),
|
|
));
|
|
}
|
|
|
|
bytes.addAll(generator.text('--------------------------------',
|
|
styles: PosStyles(align: PosAlign.center)));
|
|
|
|
return bytes;
|
|
}
|
|
|
|
/// Add footer information including disclaimer and thanks
|
|
static List<int> _addFooter(Generator generator, StoreConfig config) {
|
|
List<int> bytes = [];
|
|
|
|
// Add disclaimer
|
|
bytes.addAll(generator.feed(1));
|
|
bytes.addAll(_wrapAndAddText(generator, config.disclaimer));
|
|
|
|
// Add thank you message
|
|
bytes.addAll(generator.feed(1));
|
|
bytes.addAll(generator.text(config.thankYouText,
|
|
styles: PosStyles(align: PosAlign.center, bold: true)));
|
|
|
|
// Add pantun if exists
|
|
if (config.pantunText.isNotEmpty) {
|
|
bytes.addAll(generator.feed(1));
|
|
final pantunLines = config.pantunText.split('\n');
|
|
for (final line in pantunLines) {
|
|
bytes.addAll(
|
|
generator.text(line, styles: PosStyles(align: PosAlign.center)));
|
|
}
|
|
}
|
|
|
|
return bytes;
|
|
}
|
|
|
|
/// Wrap long text into multiple lines
|
|
static List<int> _wrapAndAddText(Generator generator, String text) {
|
|
List<int> bytes = [];
|
|
try {
|
|
final words = text.split(' ');
|
|
final List<String> wrappedLines = [];
|
|
String currentLine = '';
|
|
|
|
for (final word in words) {
|
|
if ((currentLine + word).length > 32) {
|
|
wrappedLines.add(currentLine.trim());
|
|
currentLine = word + ' ';
|
|
} else {
|
|
currentLine += word + ' ';
|
|
}
|
|
}
|
|
if (currentLine.trim().isNotEmpty) {
|
|
wrappedLines.add(currentLine.trim());
|
|
}
|
|
|
|
for (final line in wrappedLines) {
|
|
bytes.addAll(
|
|
generator.text(line, styles: PosStyles(align: PosAlign.center)));
|
|
}
|
|
} catch (e) {
|
|
print('Error saat memproses teks: $e');
|
|
// Fallback jika ada error
|
|
bytes.addAll(
|
|
generator.text(text, styles: PosStyles(align: PosAlign.center)));
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
/// Format angka ke rupiah
|
|
static String _formatRupiah(double amount) {
|
|
final formatter = NumberFormat("#,##0", "id_ID");
|
|
return "Rp ${formatter.format(amount)}";
|
|
}
|
|
|
|
/// Format angka compact tanpa Rp dan separator
|
|
static String _formatRupiahCompact(double amount) {
|
|
return NumberFormat("0", "id_ID").format(amount);
|
|
}
|
|
|
|
/// Format qty x price compact: "2x65000"
|
|
static String _compactQtyPrice(double quantity, double price) {
|
|
final qtyInt = quantity.toInt();
|
|
final priceStr = NumberFormat("0", "id_ID").format(price);
|
|
return "${qtyInt}x$priceStr";
|
|
}
|
|
|
|
/// Pad left/right text into one line to avoid ESC a alignment issues in row()
|
|
static String _padRow(String left, String right, {int maxChars = 32}) {
|
|
final padding = maxChars - left.length - right.length;
|
|
return left + ' ' * (padding > 0 ? padding : 1) + right;
|
|
}
|
|
|
|
/// Mencetak struk ke printer thermal
|
|
static Future<void> printToThermalPrinter({
|
|
required List<ReceiptGroup> groups,
|
|
required DateTime transactionDate,
|
|
String? destinationAccountName,
|
|
required BuildContext context,
|
|
required dynamic bluetoothService,
|
|
PaymentInfo? paymentInfo,
|
|
String? tipSourceAccountName,
|
|
}) async {
|
|
try {
|
|
// Generate struk dalam format byte array
|
|
final bytes = await generateEscPosBytes(
|
|
groups: groups,
|
|
transactionDate: transactionDate,
|
|
destinationAccountName: destinationAccountName,
|
|
paymentInfo: paymentInfo,
|
|
tipSourceAccountName: tipSourceAccountName,
|
|
);
|
|
|
|
// Verifikasi koneksi sebelum mencetak
|
|
final isConnectedBeforePrint = await bluetoothService.checkConnection();
|
|
if (!isConnectedBeforePrint) {
|
|
throw SocketException('Printer tidak terhubung saat akan mencetak');
|
|
}
|
|
|
|
try {
|
|
// Konversi List<int> ke Uint8List
|
|
final Uint8List data = Uint8List.fromList(bytes);
|
|
await bluetoothService.printReceipt(data);
|
|
} on SocketException catch (e) {
|
|
PrintErrorHandler.logPrintError('SEND_TO_PRINTER', e);
|
|
throw SocketException('Koneksi ke printer terputus: ${e.message}');
|
|
} on PlatformException catch (e) {
|
|
PrintErrorHandler.logPrintError('SEND_TO_PRINTER', e);
|
|
throw PlatformException(
|
|
code: e.code, message: 'Error printer: ${e.message}');
|
|
} catch (printError) {
|
|
PrintErrorHandler.logPrintError(
|
|
'SEND_TO_PRINTER', printError as Exception);
|
|
throw Exception('Gagal mengirim perintah cetak: $printError');
|
|
}
|
|
} on SocketException {
|
|
rethrow;
|
|
} on PlatformException {
|
|
rethrow;
|
|
} catch (e, stackTrace) {
|
|
PrintErrorHandler.logPrintError(
|
|
'PRINT_THERMAL_PRINTER', e as Exception, stackTrace);
|
|
throw Exception('Gagal mencetak struk: $e');
|
|
}
|
|
}
|
|
}
|