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 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 _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> generateEscPosBytes({ required List 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 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 _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> _addStoreLogo( Generator generator, String? logoPath) async { List 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 _addStoreHeader( Generator generator, StoreConfig config, String formattedDate) { List 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 _addItemList(Generator generator, List items) { List 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 _addTotals( Generator generator, List groups, PaymentInfo? paymentInfo, {String? tipSourceAccountName}) { List 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 _addPaymentInfo( Generator generator, double totalAmount, PaymentInfo paymentInfo, {String? tipSourceAccountName}) { List 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 _addFooter(Generator generator, StoreConfig config) { List 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 _wrapAndAddText(Generator generator, String text) { List bytes = []; try { final words = text.split(' '); final List 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 printToThermalPrinter({ required List 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 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'); } } }