From 3baa17e9a55490f61eb966806207bbfd7dac78cc Mon Sep 17 00:00:00 2001 From: a2nr Date: Sun, 24 Aug 2025 11:02:07 +0700 Subject: [PATCH] Refactor ReceiptScreen: Memindahkan logika kompleks ke service terpisah dan membuat widget terpisah untuk SpeedDial --- lib/extensions/double_extensions.dart | 9 + lib/screens/receipt_screen.dart | 1773 +++++----------------- lib/services/account_dialog_service.dart | 107 ++ lib/services/bluetooth_service.dart | 140 ++ lib/services/esc_pos_print_service.dart | 72 + lib/widgets/receipt_speed_dial.dart | 124 ++ lib/widgets/receipt_total.dart | 47 + 7 files changed, 907 insertions(+), 1365 deletions(-) create mode 100644 lib/extensions/double_extensions.dart create mode 100644 lib/services/account_dialog_service.dart create mode 100644 lib/services/bluetooth_service.dart create mode 100644 lib/widgets/receipt_speed_dial.dart create mode 100644 lib/widgets/receipt_total.dart diff --git a/lib/extensions/double_extensions.dart b/lib/extensions/double_extensions.dart new file mode 100644 index 0000000..d6f7b37 --- /dev/null +++ b/lib/extensions/double_extensions.dart @@ -0,0 +1,9 @@ +import 'package:intl/intl.dart'; + +extension DoubleFormatting on double { + /// Memformat angka menjadi format mata uang Rupiah + String toRupiah() { + final formatter = NumberFormat("#,##0", "id_ID"); + return "Rp ${formatter.format(this)}"; + } +} \ No newline at end of file diff --git a/lib/screens/receipt_screen.dart b/lib/screens/receipt_screen.dart index 1e20ef1..714716c 100644 --- a/lib/screens/receipt_screen.dart +++ b/lib/screens/receipt_screen.dart @@ -1,1381 +1,424 @@ import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -// import 'dart:io'; // Add this import for file operations // PDF Removed -import 'dart:typed_data'; // Untuk Uint8List -import 'package:intl/intl.dart'; // Untuk format angka +import 'dart:typed_data'; import 'package:cashumit/models/receipt_item.dart'; import 'package:cashumit/screens/add_item_screen.dart'; -// import 'package:cashumit/services/pdf_export_service.dart'; // PDF Removed -import 'package:cashumit/services/firefly_api_service.dart'; -import 'package:cashumit/services/struk_text_generator.dart'; // Tambahkan import ini -import 'package:cashumit/services/esc_pos_print_service.dart'; // Tambahkan import ini import 'package:bluetooth_print/bluetooth_print.dart'; -import 'package:bluetooth_print/bluetooth_print_model.dart'; -import 'package:flutter_speed_dial/flutter_speed_dial.dart'; // Tambahkan import ini +import 'package:cashumit/services/bluetooth_service.dart'; +import 'package:cashumit/widgets/receipt_body.dart'; // Import widget komponen struk baru import 'package:cashumit/widgets/receipt_tear_effect.dart'; -import 'package:cashumit/widgets/store_info_widget.dart'; -import 'package:cashumit/widgets/dotted_line.dart'; -import 'package:cashumit/widgets/receipt_item_widget.dart'; -import 'package:cashumit/widgets/add_item_button.dart'; -import 'package:cashumit/widgets/store_disclaimer.dart'; -import 'package:cashumit/widgets/thank_you_pantun.dart'; import 'package:cashumit/widgets/store_info_config_dialog.dart'; import 'package:cashumit/widgets/custom_text_config_dialog.dart'; -import 'package:cashumit/widgets/account_settings_widget.dart'; -import 'package:cashumit/widgets/horizontal_divider.dart'; // Tambahkan import ini -// import 'package:cashumit/widgets/struk_printer.dart'; // DIHAPUS import 'package:cashumit/screens/webview_screen.dart'; -// import 'package:google_fonts/google_fonts.dart'; // DIHAPUS karena tidak digunakan di file ini +import 'package:cashumit/widgets/receipt_speed_dial.dart'; + +// Import service baru +import 'package:cashumit/services/account_dialog_service.dart'; +import 'package:cashumit/services/esc_pos_print_service.dart'; + +// Import provider +import 'package:provider/provider.dart'; +import 'package:cashumit/providers/receipt_provider.dart'; class ReceiptScreen extends StatefulWidget { - const ReceiptScreen({super.key}); + const ReceiptScreen({super.key}); - @override - State createState() => _ReceiptScreenState(); + @override + State createState() => _ReceiptScreenState(); } class _ReceiptScreenState extends State { - List items = []; - - final String cashierId = 'KSR001'; - final String transactionId = 'TXN202508200001'; - - // Fields for transaction settings directly in main UI - late DateTime _transactionDate; - List> _accounts = []; - String? _sourceAccountId; - String? _sourceAccountName; - String? _destinationAccountId; - String? _destinationAccountName; - - // Controllers for manual account input - final TextEditingController _sourceAccountController = - TextEditingController(); - final TextEditingController _destinationAccountController = - TextEditingController(); - - // Bluetooth printer variables - late BluetoothPrint bluetoothPrint; - bool _bluetoothConnected = false; - BluetoothDevice? _bluetoothDevice; - bool _isPrinting = false; // State variable to track printing status - - @override - void initState() { - super.initState(); - print('Inisialisasi ReceiptScreen...'); - bluetoothPrint = BluetoothPrint.instance; - _transactionDate = DateTime.now(); - _loadCredentialsAndAccounts(); - _initBluetooth(); - _loadSavedBluetoothDevice(); - print('Selesai inisialisasi ReceiptScreen'); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - // Memuat ulang kredensial dan akun setiap kali ada perubahan dependencies - // Ini memastikan akun dimuat ulang ketika pengguna kembali dari ConfigScreen - _loadCredentialsAndAccounts(); - } - - @override - void dispose() { - _sourceAccountController.dispose(); - _destinationAccountController.dispose(); - super.dispose(); - } - - String? _fireflyUrl; - String? _accessToken; - - /// Memuat kredensial dari shared preferences dan kemudian memuat akun. - /// - /// Metode ini dipanggil saat: - /// 1. Widget pertama kali dibuat (initState) - /// 2. Pengguna kembali dari ConfigScreen (melalui _openSettings) - /// 3. Pengguna mengetuk tombol "Muat Ulang Akun" - /// - /// Jika kredensial berubah atau daftar akun kosong, metode ini akan memanggil - /// _loadAccounts untuk memperbarui daftar akun dari API Firefly III. - Future _loadCredentialsAndAccounts() async { - final prefs = await SharedPreferences.getInstance(); - final url = prefs.getString('firefly_url'); - final token = prefs.getString('firefly_token'); - - if (url == null || token == null || url.isEmpty || token.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Kredensial Firefly III belum dikonfigurasi. Silakan atur di menu Pengaturan.')), - ); - } - return; - } - - // Periksa apakah kredensial berubah - final credentialsChanged = _fireflyUrl != url || _accessToken != token; - - setState(() { - _fireflyUrl = url; - _accessToken = token; - }); - - // Jika kredensial ada dan berubah, lanjutkan untuk memuat akun - if (credentialsChanged) { - _loadAccounts(); - } else if (_accounts.isEmpty) { - // Jika akun belum pernah dimuat, muat sekarang - _loadAccounts(); - } - } - - /// Memuat device bluetooth yang tersimpan - Future _loadSavedBluetoothDevice() async { - final prefs = await SharedPreferences.getInstance(); - final deviceAddress = prefs.getString('bluetooth_device_address'); - final deviceName = prefs.getString('bluetooth_device_name'); - - if (deviceAddress != null && deviceName != null) { - final device = BluetoothDevice(); - device.name = deviceName; - device.address = deviceAddress; - - setState(() { - _bluetoothDevice = device; - }); - } - } - - /// Memeriksa status koneksi Bluetooth printer secara real-time - Future _checkBluetoothConnection() async { - try { - final isConnected = await bluetoothPrint.isConnected ?? false; - print('Status koneksi Bluetooth printer (real-time): $isConnected'); - - // Update state jika perlu - if (isConnected != _bluetoothConnected) { - setState(() { - _bluetoothConnected = isConnected; - }); - print('Status koneksi Bluetooth diperbarui di state: $isConnected'); - } - - return isConnected; - } catch (e) { - print('Error saat memeriksa koneksi Bluetooth: $e'); - return false; - } - } - - /// Memuat daftar akun sumber (revenue) dan tujuan (asset) dari API. - /// - /// Metode ini mengambil akun revenue dan asset secara terpisah dari API Firefly III, - /// kemudian menggabungkannya ke dalam satu daftar `_accounts` yang digunakan - /// untuk mengisi dropdown akun sumber dan tujuan. - /// - /// Jika berhasil memuat akun, metode ini akan menampilkan pesan jumlah akun yang dimuat. - /// Jika tidak ada akun yang dimuat, metode ini akan menampilkan pesan peringatan. - /// Jika terjadi kesalahan saat memuat akun, metode ini akan menampilkan pesan kesalahan. - Future _loadAccounts() async { - if (_fireflyUrl == null || _accessToken == null) { - return; - } - - try { - // Tampilkan pesan loading - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Memuat daftar akun dari Firefly III...')), - ); - } - - // Mengambil akun revenue - final revenueAccounts = await FireflyApiService.fetchAccounts( - baseUrl: _fireflyUrl!, - accessToken: _accessToken!, - type: 'revenue', - ); - - // Mengambil akun asset - final assetAccounts = await FireflyApiService.fetchAccounts( - baseUrl: _fireflyUrl!, - accessToken: _accessToken!, - type: 'asset', - ); - - // Menggabungkan akun revenue dan asset untuk dropdown - final allAccounts = >[]; - for (var account in revenueAccounts) { - allAccounts.add({ - 'id': account.id, - 'name': account.name, - 'type': account.type, - }); - } - for (var account in assetAccounts) { - allAccounts.add({ - 'id': account.id, - 'name': account.name, - 'type': account.type, - }); - } - - setState(() { - _accounts = allAccounts; - }); - - if (mounted) { - if (allAccounts.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Tidak ada akun yang ditemukan. Pastikan kredensial dan koneksi internet Anda benar.')), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Berhasil memuat ${allAccounts.length} akun dari Firefly III')), - ); - } - } - } catch (error) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Gagal memuat akun: $error')), - ); - } - } - } - - /// Inisialisasi Bluetooth printer - Future _initBluetooth() async { - // Memeriksa status koneksi Bluetooth - final isConnected = await bluetoothPrint.isConnected ?? false; - - if (isConnected) { - setState(() { - _bluetoothConnected = true; - }); - } - - // Listen to bluetooth state changes - bluetoothPrint.state.listen((state) { - if (mounted) { - switch (state) { - case BluetoothPrint.connected: - setState(() { - _bluetoothConnected = true; - }); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Printer terhubung')), - ); - } - break; - case BluetoothPrint.disconnected: - setState(() { - _bluetoothConnected = false; - // Jangan set _bluetoothDevice ke null, biarkan device yang sudah dipilih - }); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Printer terputus')), - ); - } - break; - default: - break; - } - } - }); - } - - /// Cetak struk ke printer thermal - Future _printToThermalPrinter() async { - print('=== FUNGSI _printToThermalPrinter DIPANGGIL ==='); - print('Memulai proses pencetakan struk...'); - print('Jumlah item: ${items.length}'); - print('Tanggal transaksi: $_transactionDate'); - print('ID kasir: $cashierId'); - print('ID transaksi: $transactionId'); - - // Periksa koneksi printer secara real-time - bool isConnected = await _checkBluetoothConnection(); - print('Status koneksi printer awal (real-time): $isConnected'); - - if (!isConnected) { - print('Printer tidak terhubung'); - if (!mounted) return; - // Coba sambungkan kembali jika ada device yang tersimpan - if (_bluetoothDevice != null) { - print('Mencoba menyambungkan kembali ke printer: ${_bluetoothDevice!.name}'); - print('Alamat printer: ${_bluetoothDevice!.address}'); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Mencoba menyambungkan kembali ke printer...')), - ); - try { - // Putuskan koneksi yang mungkin tersisa - try { - await bluetoothPrint.disconnect(); - print('Berhasil memutuskan koneksi yang tersisa'); - } catch (disconnectError) { - print('Tidak ada koneksi yang perlu diputuskan: $disconnectError'); - } - - // Tunggu sebentar sebelum menyambungkan kembali - await Future.delayed(const Duration(milliseconds: 500)); - - // Sambungkan kembali - await bluetoothPrint.connect(_bluetoothDevice!); - print('Berhasil menyambungkan kembali ke printer'); - - // Tunggu sebentar untuk memastikan koneksi stabil - await Future.delayed(const Duration(milliseconds: 500)); - - // Periksa koneksi lagi - isConnected = await _checkBluetoothConnection(); - print('Status koneksi printer setelah reconnect (real-time): $isConnected'); - - if (isConnected) { - // Perbarui status koneksi - setState(() { - _bluetoothConnected = true; - }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Printer berhasil terhubung kembali')), - ); - } else { - throw Exception('Koneksi tidak stabil setelah reconnect'); - } - } catch (e) { - print('Gagal menyambungkan kembali ke printer: $e'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Gagal menyambungkan kembali ke printer: $e')), - ); - return; - } - } else { - print('Tidak ada device printer yang tersimpan'); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Harap hubungkan printer terlebih dahulu')), - ); - return; - } - } else { - print('Printer sudah terhubung'); - } - - try { - print('Menghasilkan byte array ESC/POS menggunakan flutter_esc_pos_utils...'); - print('Jumlah item: ${items.length}'); - print('Tanggal transaksi: $_transactionDate'); - print('ID kasir: $cashierId'); - print('ID transaksi: $transactionId'); - - // Generate struk dalam format byte array menggunakan EscPosPrintService - final bytes = await EscPosPrintService.generateEscPosBytes( - items: items, - transactionDate: _transactionDate, - cashierId: cashierId, - transactionId: transactionId, - ); - - print('Byte array ESC/POS berhasil dihasilkan'); - print('Jumlah byte: ${bytes.length}'); - - // Tampilkan byte array untuk debugging (dalam format hex) - print('Isi byte array (hex):'); - if (bytes.length <= 1000) { // Batasi tampilan untuk mencegah output terlalu panjang - print(bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')); - } else { - print('Terlalu banyak byte untuk ditampilkan (${bytes.length} bytes)'); - } - - // Verifikasi koneksi sebelum mencetak - final isConnectedBeforePrint = await bluetoothPrint.isConnected ?? false; - if (!isConnectedBeforePrint) { - print('Printer tidak terhubung saat akan mencetak'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Printer tidak terhubung saat akan mencetak')), - ); - return; - } - - print('Mengirim byte array ke printer...'); - bool printSuccess = false; - String printError = ''; - - try { - // Coba kirim byte array beberapa kali jika gagal - for (int attempt = 1; attempt <= 3; attempt++) { - print('Percobaan cetak ke-$attempt'); - try { - // Konversi List ke Uint8List - final Uint8List data = Uint8List.fromList(bytes); - await bluetoothPrint.printRawData(data); - print('Perintah cetak berhasil dikirim pada percobaan ke-$attempt'); - printSuccess = true; - break; - } catch (e) { - print('Error saat mengirim perintah cetak pada percobaan ke-$attempt: $e'); - printError = e.toString(); - if (attempt < 3) { - // Tunggu sebentar sebelum mencoba lagi - await Future.delayed(const Duration(milliseconds: 500)); - } - } - } - - if (printSuccess) { - print('Perintah cetak berhasil dikirim'); - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Perintah cetak dikirim ke printer')), - ); - } else { - print('Gagal mengirim perintah cetak setelah 3 percobaan: $printError'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Gagal mengirim perintah cetak: $printError')), - ); - return; - } - } catch (printError) { - print('Error saat mengirim perintah cetak ke printer: $printError'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Gagal mengirim perintah cetak: $printError')), - ); - return; - } - } catch (e, stackTrace) { - print('Error saat mencetak struk: $e'); - print('Stack trace: $stackTrace'); - // Cetak detail error tambahan - print('Tipe error: ${e.runtimeType}'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Gagal mencetak struk: $e')), - ); - return; - } - } - - void _addItem() async { - final newItem = await showDialog( - context: context, - builder: (context) => const AddItemScreen(), // Tampilkan sebagai dialog - ); - - if (newItem != null) { - setState(() { - items.add(newItem); - }); - } - } - - void _editItem(int index) async { - // Pastikan index valid sebelum mengakses item - if (index < 0 || index >= items.length) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Gagal mengedit item: Index tidak valid.")), - ); - } - return; - } - - final originalItem = - items[index]; // Simpan item asli untuk fallback jika diperlukan - - final editedItem = await showDialog( - context: context, - builder: (context) => - AddItemScreen.fromItem(originalItem), // Tampilkan sebagai dialog - ); - - // Hanya update jika item tidak null (bukan hasil dari 'Batal') - if (editedItem != null) { - setState(() { - items[index] = editedItem; - }); - } - // Jika editedItem null (dibatalkan), tidak ada perubahan pada items[index] - } - - /// Menampilkan dialog konfirmasi sebelum menghapus item - /// - /// Mengembalikan Future yang menunjukkan apakah pengguna mengkonfirmasi penghapusan. - /// - true: Pengguna mengkonfirmasi. - /// - false: Pengguna membatalkan. - /// - null: Dialog ditutup tanpa memilih opsi. - Future _confirmRemoveItem(int index) async { - // Validasi index untuk mencegah error - if (index < 0 || index >= items.length) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Gagal menghapus item: Index tidak valid.")), - ); - } - return false; // Atau throw exception tergantung kebijakan error handling - } - - // Simpan referensi item sebelum dialog muncul - final itemToRemove = items[index]; - - final bool? shouldRemove = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Konfirmasi Hapus'), - content: Text( - 'Apakah Anda yakin ingin menghapus item ${itemToRemove.description}'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), // Batal - child: const Text('Batal'), - ), - TextButton( - onPressed: () => Navigator.of(context).pop(true), // Hapus - child: const Text('Hapus'), - ), - ], - ), - ); - - // Tampilkan snackbar informasi berdasarkan hasil konfirmasi - if (shouldRemove == true) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Item '${itemToRemove.description}' akan dihapus setelah gestur selesai.")), - ); - } - } else if (shouldRemove == false) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Penghapusan item '${itemToRemove.description}' dibatalkan.")), - ); - } - } - // Jika shouldRemove == null, pengguna menutup dialog, tidak perlu snackbar tambahan - - return shouldRemove; - } - - /// Menampilkan dialog untuk memilih akun sumber (revenue). - /// - /// Metode ini memfilter daftar akun untuk hanya menampilkan akun dengan tipe 'revenue', - /// kemudian menampilkan dialog dengan daftar akun tersebut. Ketika pengguna memilih - /// akun, metode ini akan memperbarui state `_sourceAccountId` dan `_sourceAccountName`. - Future _selectSourceAccount() async { - if (_accounts.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Daftar akun kosong. Pastikan kredensial sudah diatur dan akun telah dimuat. Klik "Muat Ulang Akun" untuk mencoba lagi.')), - ); - return; - } - - // Filter akun sumber (revenue) - final revenueAccounts = - _accounts.where((account) => account['type'] == 'revenue').toList(); - - if (revenueAccounts.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Tidak ada akun sumber (revenue) yang ditemukan. Klik "Muat Ulang Akun" untuk mencoba lagi atau periksa akun Anda di Firefly III.')), - ); - return; - } - - final selectedAccount = await showDialog?>( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Pilih Akun Sumber'), - content: SizedBox( - width: double.maxFinite, - child: ListView.builder( - shrinkWrap: true, - itemCount: revenueAccounts.length, - itemBuilder: (context, index) { - final account = revenueAccounts[index]; - return ListTile( - title: Text(account['name']), - subtitle: Text(account['type']), - onTap: () => Navigator.of(context).pop(account), - ); - }, - ), - ), - ); - }, - ); - - if (selectedAccount != null) { - setState(() { - _sourceAccountId = selectedAccount['id']; - _sourceAccountName = selectedAccount['name']; - // Update controller with selected account name - _sourceAccountController.text = selectedAccount['name']; - }); - } - } - - /// Menampilkan dialog untuk memilih akun tujuan (asset). - /// - /// Metode ini memfilter daftar akun untuk hanya menampilkan akun dengan tipe 'asset', - /// kemudian menampilkan dialog dengan daftar akun tersebut. Ketika pengguna memilih - /// akun, metode ini akan memperbarui state `_destinationAccountId` dan `_destinationAccountName`. - Future _selectDestinationAccount() async { - if (_accounts.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Daftar akun kosong. Pastikan kredensial sudah diatur dan akun telah dimuat. Klik "Muat Ulang Akun" untuk mencoba lagi.')), - ); - return; - } - - // Filter akun tujuan (asset) - final assetAccounts = - _accounts.where((account) => account['type'] == 'asset').toList(); - - if (assetAccounts.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Tidak ada akun tujuan (asset) yang ditemukan. Klik "Muat Ulang Akun" untuk mencoba lagi atau periksa akun Anda di Firefly III.')), - ); - return; - } - - final selectedAccount = await showDialog?>( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Pilih Akun Tujuan'), - content: SizedBox( - width: double.maxFinite, - child: ListView.builder( - shrinkWrap: true, - itemCount: assetAccounts.length, - itemBuilder: (context, index) { - final account = assetAccounts[index]; - return ListTile( - title: Text(account['name']), - subtitle: Text(account['type']), - onTap: () => Navigator.of(context).pop(account), - ); - }, - ), - ), - ); - }, - ); - - if (selectedAccount != null) { - setState(() { - _destinationAccountId = selectedAccount['id']; - _destinationAccountName = selectedAccount['name']; - // Update controller with selected account name - _destinationAccountController.text = selectedAccount['name']; - }); - } - } - - /// Mencari ID akun berdasarkan nama dan tipe akun. - /// - /// Metode ini digunakan ketika pengguna memasukkan nama akun secara manual - /// (bukan memilih dari dropdown). Metode ini akan mencari akun dengan nama - /// yang cocok dan tipe yang diharapkan (revenue untuk sumber, asset untuk tujuan). - /// - /// Jika tidak ditemukan akun yang cocok, metode ini akan mencoba pencarian - /// yang lebih fleksibel dengan mencari apakah nama akun mengandung teks yang dimasukkan. - /// - /// Parameter: - /// - [name]: Nama akun yang dicari - /// - [expectedType]: Tipe akun yang diharapkan (revenue atau asset) - /// - /// Mengembalikan ID akun jika ditemukan, atau null jika tidak ditemukan. - String? _findAccountIdByName(String name, String expectedType) { - if (name.isEmpty) return null; - - try { - // Cari akun dengan nama yang cocok dan tipe yang diharapkan - final account = _accounts.firstWhere( - (account) => - account['name'].toString().toLowerCase() == name.toLowerCase() && - account['type'] == expectedType, - ); - - return account['id'] as String?; - } catch (e) { - // Jika tidak ditemukan, coba pencarian yang lebih fleksibel - for (var account in _accounts) { - if (account['type'] == expectedType && - account['name'] - .toString() - .toLowerCase() - .contains(name.toLowerCase())) { - return account['id'] as String?; - } - } - - // Jika masih tidak ditemukan, kembalikan null - return null; - } - } - - /// Generates a transaction description based on item names - String _generateTransactionDescription() { - if (items.isEmpty) { - return 'Transaksi Struk Belanja'; - } - - // Take the first 5 item descriptions - final itemNames = items.take(5).map((item) => item.description).toList(); - - // If there are more than 5 items, append ', dll' to the last item - if (items.length > 5) { - itemNames[4] += ', dll'; - } - - // Join the item names with ', ' - return itemNames.join(', '); - } - - /// Mengirim transaksi ke Firefly III. - /// - /// Sebelum mengirim transaksi, metode ini melakukan beberapa validasi: - /// 1. Memastikan ada item yang akan dikirim - /// 2. Memastikan akun sumber dan tujuan telah dipilih atau dimasukkan - /// 3. Memastikan akun sumber dan tujuan berbeda - /// 4. Memastikan akun sumber dan tujuan ada di daftar akun yang dimuat - /// 5. Memastikan tipe akun sesuai (sumber: revenue, tujuan: asset) - /// - /// Jika semua validasi lolos, metode ini akan mengirim transaksi ke API Firefly III - /// dan menampilkan pesan hasilnya. - Future _sendToFirefly() async { - if (items.isEmpty) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Tidak ada item untuk dikirim')), - ); - return; - } - - // Cek apakah user memasukkan akun secara manual - if (_sourceAccountId == null && - _sourceAccountName != null && - _sourceAccountName!.isNotEmpty) { - _sourceAccountId = _findAccountIdByName(_sourceAccountName!, 'revenue'); - // Jika tidak ditemukan, coba cari dengan pendekatan yang lebih fleksibel - if (_sourceAccountId == null) { - for (var account in _accounts) { - if (account['type'] == 'revenue' && - account['name'] - .toString() - .toLowerCase() - .contains(_sourceAccountName!.toLowerCase())) { - _sourceAccountId = account['id'] as String?; - _sourceAccountName = account['name'] as String?; - _sourceAccountController.text = _sourceAccountName!; - break; - } - } - } - } - - if (_destinationAccountId == null && - _destinationAccountName != null && - _destinationAccountName!.isNotEmpty) { - _destinationAccountId = - _findAccountIdByName(_destinationAccountName!, 'asset'); - // Jika tidak ditemukan, coba cari dengan pendekatan yang lebih fleksibel - if (_destinationAccountId == null) { - for (var account in _accounts) { - if (account['type'] == 'asset' && - account['name'] - .toString() - .toLowerCase() - .contains(_destinationAccountName!.toLowerCase())) { - _destinationAccountId = account['id'] as String?; - _destinationAccountName = account['name'] as String?; - _destinationAccountController.text = _destinationAccountName!; - break; - } - } - } - } - - // Validasi input - if (_sourceAccountId == null || _destinationAccountId == null) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Silakan pilih atau masukkan akun sumber dan tujuan yang valid. ' - 'Anda bisa memilih dari daftar atau mengetik nama akun yang sesuai.'), - duration: const Duration(seconds: 5), - ), - ); - return; - } - - if (_sourceAccountId == _destinationAccountId) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Akun sumber dan tujuan tidak boleh sama')), - ); - return; - } - - // Validasi apakah akun benar-benar ada di Firefly III - bool sourceAccountExists = false; - bool destinationAccountExists = false; - - // Cari detail akun untuk validasi tipe akun - Map? sourceAccountDetails; - Map? destinationAccountDetails; - - for (var account in _accounts) { - if (account['id'].toString() == _sourceAccountId) { - sourceAccountExists = true; - sourceAccountDetails = account; - } - if (account['id'].toString() == _destinationAccountId) { - destinationAccountExists = true; - destinationAccountDetails = account; - } - } - - if (!sourceAccountExists) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Akun sumber tidak ditemukan di daftar akun yang dimuat. Klik "Muat Ulang Akun" dan coba lagi.')), - ); - return; - } - - if (!destinationAccountExists) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Akun tujuan tidak ditemukan di daftar akun yang dimuat. Klik "Muat Ulang Akun" dan coba lagi.')), - ); - return; - } - - // Validasi tipe akun (sumber harus revenue, tujuan harus asset) - if (sourceAccountDetails != null && - sourceAccountDetails['type'] != 'revenue') { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Peringatan: Akun sumber sebaiknya bertipe revenue, tetapi akun ini bertipe ${sourceAccountDetails['type']}. Transaksi mungkin tidak akan berhasil.')), - ); - } - - if (destinationAccountDetails != null && - destinationAccountDetails['type'] != 'asset') { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Peringatan: Akun tujuan sebaiknya bertipe asset, tetapi akun ini bertipe ${destinationAccountDetails['type']}. Transaksi mungkin tidak akan berhasil.')), - ); - } - - final total = _calculateTotal(); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Mengirim transaksi ke Firefly III...')), - ); - - // Generate transaction description - final transactionDescription = _generateTransactionDescription(); - - String? transactionId; // To store the transaction ID for attachment - if (_fireflyUrl != null && _accessToken != null) { - transactionId = await FireflyApiService.submitDummyTransaction( - baseUrl: _fireflyUrl!, - accessToken: _accessToken!, - sourceId: _sourceAccountId!, - destinationId: _destinationAccountId!, - type: 'deposit', - description: transactionDescription, // Use the generated description - date: - '${_transactionDate.year}-${_transactionDate.month.toString().padLeft(2, '0')}-${_transactionDate.day.toString().padLeft(2, '0')}', - amount: total.toStringAsFixed(2), - ); - } - - if (!mounted) return; - if (transactionId != null && transactionId != "success") { - // PDF generation and upload removed as per request - // Navigasi ke WebViewScreen untuk menampilkan transaksi - if (_fireflyUrl != null) { - final transactionUrl = '$_fireflyUrl/transactions/show/$transactionId'; - if (mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => WebViewScreen( - url: transactionUrl, - title: 'Transaksi Firefly III', - ), - ), - ); - } - } - } else if (transactionId == "success") { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: - Text('Transaksi berhasil dikirim ke Firefly III (tanpa ID)')), - ); - } else { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Gagal mengirim transaksi ke Firefly III. Periksa log untuk detail kesalahan.')), - ); - } - } - - /// Membuka dialog konfigurasi informasi toko - Future _openStoreInfoConfig() async { - final result = await showDialog( - context: context, - builder: (context) => const StoreInfoConfigDialog(), - ); - - // Jika informasi toko berhasil disimpan, refresh widget - if (result == true) { - setState(() { - // setState akan memicu rebuild, sehingga StoreInfoWidget akan memuat ulang data - }); - } - } - - /// Membuka dialog konfigurasi teks kustom (disclaimer, thank you, pantun) - Future _openCustomTextConfig() async { - final result = await showDialog( - context: context, - builder: (context) => const CustomTextConfigDialog(), - ); - - // Jika teks berhasil disimpan, refresh widget - if (result == true) { - setState(() { - // setState akan memicu rebuild, sehingga StoreDisclaimer dan ThankYouPantun akan memuat ulang data - }); - } - } - - /// Membuka layar konfigurasi. - /// - /// Jika pengguna menyimpan konfigurasi di ConfigScreen, metode ini akan - /// menerima hasil `true` dan memanggil `_loadCredentialsAndAccounts` untuk - /// memuat ulang kredensial dan akun dengan pengaturan baru. - void _openSettings() async { - final result = await Navigator.pushNamed(context, '/config'); - // Jika pengguna kembali dari ConfigScreen dengan hasil true, muat ulang kredensial dan akun - if (result == true) { - _loadCredentialsAndAccounts(); - } - } - - /// Memformat angka menjadi format mata uang Rupiah - /// Memformat angka menjadi format mata uang Rupiah - String _formatRupiah(double amount) { - // Menggunakan NumberFormat untuk memformat angka - final formatter = NumberFormat("#,##0", "id_ID"); - return "Rp ${formatter.format(amount)}"; - } - - final TextStyle baseTextStyle = const TextStyle( - fontFamily: 'Courier', // Gunakan font courier jika tersedia - fontSize: 14, - height: 1.2, - ); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: - Colors.grey[300], // Latar belakang abu-abu untuk efek struk - floatingActionButton: SpeedDial( - icon: Icons.menu, - activeIcon: Icons.close, - spacing: 3, - spaceBetweenChildren: 4, - children: [ - SpeedDialChild( - child: const Icon(Icons.send), - label: 'Kirim ke Firefly', - onTap: items.isNotEmpty && - _sourceAccountId != null && - _destinationAccountId != null - ? _sendToFirefly - : () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Pilih akun sumber dan tujuan terlebih dahulu'), - duration: Duration(seconds: 2), - ), - ); - }, -backgroundColor: items.isNotEmpty && -_sourceAccountId != null && -_destinationAccountId != null -? Colors.blue -: Colors.grey, -), -SpeedDialChild( - child: const Icon(Icons.refresh), - label: 'Muat Ulang Akun', - onTap: _loadAccounts, - backgroundColor: Colors.orange, - ), -SpeedDialChild( - child: const Icon(Icons.settings), - label: 'Pengaturan', - onTap: _openSettings, - backgroundColor: Colors.green, - ), -// SpeedDialChild for PDF printing removed as per request -SpeedDialChild( - child: _isPrinting - ? const CircularProgressIndicator(color: Colors.white, strokeWidth: 2) - : const Icon(Icons.receipt), - label: 'Cetak Struk', - onTap: _isPrinting - ? null - : () async { - // Periksa koneksi secara real-time - final isConnected = await _checkBluetoothConnection(); - if (isConnected) { - _printToThermalPrinter(); - } else { - // Coba sambungkan kembali jika ada device yang tersimpan - if (_bluetoothDevice != null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Mencoba menyambungkan ke printer...')), - ); - try { - await bluetoothPrint.connect(_bluetoothDevice!); - // Tunggu sebentar untuk memastikan koneksi stabil - await Future.delayed(const Duration(milliseconds: 500)); - // Periksa koneksi lagi - final isConnectedAfterConnect = await _checkBluetoothConnection(); - if (isConnectedAfterConnect) { - _printToThermalPrinter(); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Gagal menyambungkan ke printer')), - ); - } - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Gagal menyambungkan ke printer: $e')), - ); - } - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Hubungkan printer terlebih dahulu')), - ); - } - } - }, - backgroundColor: Colors.purple, - ), -], -), -body: SafeArea( - child: Center( - // Membungkus dengan widget Center untuk memastikan struk berada di tengah - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment - .center, // Memusatkan konten secara horizontal - children: [ - // Background untuk efek kertas struk tersobek di bagian atas - Container( - width: 360, - color: const Color(0xFFE0E0E0), // Warna latar belakang sesuai dengan latar belakang layar - child: const Column( - children: [ - const SizedBox(height: 15), // Jarak atas yang lebih besar - const ReceiptTearTop(), // Efek kertas struk tersobek di bagian atas - ], - ), - ), - - // Konten struk - Container( - width: 360, // Lebar tetap untuk menyerupai struk fisik - decoration: const BoxDecoration( - color: Colors.white, - ), - child: Column( - children: [ - // Informasi toko dengan widget yang dapat diedit - StoreInfoWidget( - onTap: _openStoreInfoConfig, // Buka dialog konfigurasi saat di-tap - ), - // Garis pembatas - const DottedLine(), - const SizedBox(height: 8), - // Pengaturan akun sumber dan destinasi - AccountSettingsWidget( - sourceAccount: _sourceAccountName ?? 'Pilih Sumber', - destinationAccount: - _destinationAccountName ?? 'Pilih Tujuan', - onSelectSource: _selectSourceAccount, - onSelectDestination: _selectDestinationAccount, - ), - const SizedBox(height: 8), - // Garis pemisah - const HorizontalDivider(), - - // Baris tabel keterangan - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - flex: 4, - child: Text( - 'ITEM', - style: baseTextStyle.copyWith( - fontWeight: FontWeight.bold), - textAlign: TextAlign.left, - ), - ), - Expanded( - flex: 1, - child: Text( - 'QTY', - style: baseTextStyle.copyWith( - fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - ), - Expanded( - flex: 2, - child: Text( - 'HARGA', - style: baseTextStyle.copyWith( - fontWeight: FontWeight.bold), - textAlign: TextAlign.right, - ), - ), - Expanded( - flex: 2, - child: Text( - 'TOTAL', - style: baseTextStyle.copyWith( - fontWeight: FontWeight.bold), - textAlign: TextAlign.right, - ), - ), - ], - ), - - // Garis pembatas - const HorizontalDivider(), - - // Daftar item dengan Dismissible untuk swipe-to-delete - ...items.asMap().entries.map((entry) { - int index = entry.key; - ReceiptItem item = entry.value; - - return Dismissible( - key: Key( - '${item.hashCode}_${index}'), // Unique key combining item hash and index for stability - direction: DismissDirection - .horizontal, // Geser ke kiri atau kanan - background: Container( - color: Colors - .red, // Latar belakang merah saat digeser ke kanan - alignment: Alignment.centerLeft, - padding: const EdgeInsets.only(left: 20.0), - child: - const Icon(Icons.delete, color: Colors.white), - ), - secondaryBackground: Container( - color: Colors - .red, // Latar belakang merah saat digeser ke kiri - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20.0), - child: - const Icon(Icons.delete, color: Colors.white), - ), - confirmDismiss: (direction) async { - // Tampilkan dialog konfirmasi sebelum menghapus dan kembalikan hasilnya - final bool? shouldRemove = - await _confirmRemoveItem(index); - // Jika shouldRemove adalah null (dialog ditutup), perlakukan sebagai pembatalan (false) - return shouldRemove ?? false; - }, -onDismissed: (direction) { - // Validasi index untuk mencegah error - if (index >= 0 && index < items.length) { - // Simpan deskripsi item untuk pesan snackbar - final itemDescription = items[index].description; - - // Hapus item dari daftar - items.removeAt(index); - - // Update UI - if (mounted) { - setState(() { - // items sudah diupdate oleh removeAt di atas - }); - - // Tampilkan snackbar bahwa item telah dihapus - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - "Item '$itemDescription' telah dihapus.")), - ); - } - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - "Gagal menghapus item: Index tidak valid setelah dismiss.")), - ); - } - } - }, -child: GestureDetector( - onTap: () => _editItem(index), // Tap untuk edit - // onLongPress dihapus karena fungsi utamanya sudah digantikan oleh Dismissible - child: ReceiptItemWidget( - item: item, - ), - ), - ); - }), // .toList() dihapus karena spread operator tidak memerlukannya - - // Tombol tambah item - AddItemButton(onTap: _addItem), - - - // Garis pembatas - const HorizontalDivider(), - - // Total harga keseluruhan - Container( - width: double.infinity, - padding: const EdgeInsets.all(8.0), - color: Colors.white, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Expanded( - flex: 4, - child: Text( - 'TOTAL:', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - Expanded( - flex: 4, - child: Text( - _formatRupiah(_calculateTotal()), - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.right, - ), - ), - ], - ), - - ], - ), - ), - const SizedBox(height: 8), - // Garis pembatas - const DottedLine(), - - - // Disclaimer toko - StoreDisclaimer(onTap: _openCustomTextConfig), - - // Ucapan terima kasih dan pantun - ThankYouPantun(onTap: _openCustomTextConfig), - - const SizedBox( - height: 16), // Add some space at the very bottom - ], - ), - ), - - // Background untuk efek kertas struk tersobek di bagian bawah - Container( - width: 360, - color: const Color(0xFFE0E0E0), // Warna latar belakang sesuai dengan latar belakang layar - child: const Column( - children: [ - const ReceiptTearBottom(), // Efek kertas struk tersobek di bagian bawah - const SizedBox( - height: 15), // Jarak bawah yang lebih besar - ], - ), - ), - ], - ), - ), - ), - ), - ); - } - - /// Menghitung total harga semua item - double _calculateTotal() { - return items.fold(0.0, (sum, item) => sum + item.total); - } -} + // Bluetooth service + final BluetoothService _bluetoothService = BluetoothService(); + + @override + void initState() { + super.initState(); + print('Inisialisasi ReceiptScreen...'); + _initBluetooth(); + _loadSavedBluetoothDevice(); + print('Selesai inisialisasi ReceiptScreen'); + + // Panggil inisialisasi provider setelah widget dibuat + WidgetsBinding.instance.addPostFrameCallback((_) { + final receiptProvider = context.read(); + receiptProvider.initialize(); + }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + } + + @override + void dispose() { + super.dispose(); + } + + /// Memuat device bluetooth yang tersimpan + Future _loadSavedBluetoothDevice() async { + await _bluetoothService.loadSavedDevice(); + } + + /// Memeriksa status koneksi Bluetooth printer secara real-time + Future _checkBluetoothConnection() async { + return await _bluetoothService.checkConnection(); + } + + /// Inisialisasi Bluetooth printer + Future _initBluetooth() async { + // Inisialisasi BluetoothService + await _bluetoothService.initialize(); + + // Listen to bluetooth state changes + _bluetoothService.state.listen((state) { + if (mounted) { + switch (state) { + case BluetoothPrint.connected: + setState(() { + // Status koneksi akan dikelola oleh BluetoothService + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Printer terhubung')), + ); + } + break; + case BluetoothPrint.disconnected: + setState(() { + // Status koneksi akan dikelola oleh BluetoothService + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Printer terputus')), + ); + } + break; + default: + break; + } + } + }); + } + + /// Cetak struk ke printer thermal + Future _printToThermalPrinter() async { + final receiptProvider = context.read(); + final state = receiptProvider.state; + + // Cek dan reconnect jika perlu + final isConnected = await _bluetoothService.reconnectIfNeeded(); + if (!isConnected) { + if (!mounted) return; + if (_bluetoothService.connectedDevice != null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Gagal menyambungkan ke printer')), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Harap hubungkan printer terlebih dahulu')), + ); + } + return; + } + + try { + await EscPosPrintService.printToThermalPrinter( + items: state.items, + transactionDate: state.transactionDate, + context: context, + bluetoothService: _bluetoothService, + ); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Perintah cetak dikirim ke printer')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal mencetak struk: $e')), + ); + } + } + + void _addItem() async { + final newItem = await showDialog( + context: context, + builder: (context) => const AddItemScreen(), // Tampilkan sebagai dialog + ); + + if (newItem != null) { + // Menambahkan item melalui provider + final receiptProvider = context.read(); + receiptProvider.addItem(newItem); + } + } + + void _editItem(int index) async { + final receiptProvider = context.read(); + final state = receiptProvider.state; + + // Pastikan index valid sebelum mengakses item + if (index < 0 || index >= state.items.length) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Gagal mengedit item: Index tidak valid.")), + ); + } + return; + } + + final originalItem = + state.items[index]; // Simpan item asli untuk fallback jika diperlukan + + final editedItem = await showDialog( + context: context, + builder: (context) => + AddItemScreen.fromItem(originalItem), // Tampilkan sebagai dialog + ); + + // Hanya update jika item tidak null (bukan hasil dari 'Batal') + if (editedItem != null) { + // Memperbarui item melalui provider + receiptProvider.editItem(index, editedItem); + } + // Jika editedItem null (dibatalkan), tidak ada perubahan pada items[index] + } + + /// Menampilkan dialog untuk memilih akun sumber (revenue) + Future _selectSourceAccount() async { + final receiptProvider = context.read(); + final state = receiptProvider.state; + + final selectedAccount = await AccountDialogService.showSourceAccountDialog( + context, + state.accounts + ); + + if (selectedAccount != null) { + // Memperbarui state melalui provider + receiptProvider.selectSourceAccount( + selectedAccount['id'].toString(), + selectedAccount['name'].toString() + ); + } + } + + /// Menampilkan dialog untuk memilih akun tujuan (asset) + Future _selectDestinationAccount() async { + final receiptProvider = context.read(); + final state = receiptProvider.state; + + final selectedAccount = await AccountDialogService.showDestinationAccountDialog( + context, + state.accounts + ); + + if (selectedAccount != null) { + // Memperbarui state melalui provider + receiptProvider.selectDestinationAccount( + selectedAccount['id'].toString(), + selectedAccount['name'].toString() + ); + } + } + + /// Mengirim transaksi ke Firefly III. + Future _sendToFirefly() async { + final receiptProvider = context.read(); + final state = receiptProvider.state; + + try { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Mengirim transaksi ke Firefly III...')), + ); + + final transactionId = await receiptProvider.submitTransaction(); + + if (!mounted) return; + if (transactionId != null && transactionId != "success") { + // Navigasi ke WebViewScreen untuk menampilkan transaksi + if (state.fireflyUrl != null) { + final transactionUrl = '${state.fireflyUrl}/transactions/show/$transactionId'; + if (mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => WebViewScreen( + url: transactionUrl, + title: 'Transaksi Firefly III', + ), + ), + ); + } + } + } else if (transactionId == "success") { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Transaksi berhasil dikirim ke Firefly III (tanpa ID)')), + ); + } else { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Gagal mengirim transaksi ke Firefly III. Periksa log untuk detail kesalahan.')), + ); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal mengirim transaksi: $e')), + ); + } + } + + /// Membuka dialog konfigurasi informasi toko + Future _openStoreInfoConfig() async { + final result = await showDialog( + context: context, + builder: (context) => const StoreInfoConfigDialog(), + ); + + // Jika informasi toko berhasil disimpan, refresh widget + if (result == true) { + setState(() { + // Trigger rebuild untuk memuat ulang data + }); + } + } + + /// Membuka dialog konfigurasi teks kustom (disclaimer, thank you, pantun) + Future _openCustomTextConfig() async { + final result = await showDialog( + context: context, + builder: (context) => const CustomTextConfigDialog(), + ); + + // Jika teks berhasil disimpan, refresh widget + if (result == true) { + setState(() { + // Trigger rebuild untuk memuat ulang data + }); + } + } + + /// Membuka layar konfigurasi. + void _openSettings() async { + final result = await Navigator.pushNamed(context, '/config'); + // Jika pengguna kembali dari ConfigScreen dengan hasil true, muat ulang kredensial dan akun + if (result == true) { + final receiptProvider = context.read(); + receiptProvider.loadCredentialsAndAccounts(); + } + } + + final TextStyle baseTextStyle = const TextStyle( + fontFamily: 'Courier', // Gunakan font courier jika tersedia + fontSize: 14, + height: 1.2, + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: + Colors.grey[300], // Latar belakang abu-abu untuk efek struk + floatingActionButton: Consumer( + builder: (context, receiptProvider, child) { + final state = receiptProvider.state; + return ReceiptSpeedDial( + bluetoothService: _bluetoothService, + onCheckConnection: _checkBluetoothConnection, + onPrint: _printToThermalPrinter, + onSettings: _openSettings, + onReloadAccounts: receiptProvider.loadAccounts, + hasItems: state.items.isNotEmpty, + hasSourceAccount: state.sourceAccountId != null, + hasDestinationAccount: state.destinationAccountId != null, + onSendToFirefly: _sendToFirefly, + ); + } + ), + body: SafeArea( + child: Center( + // Membungkus dengan widget Center untuk memastikan struk berada di tengah + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment + .center, // Memusatkan konten secara horizontal + children: [ + // Background untuk efek kertas struk tersobek di bagian atas + Container( + width: 360, + color: const Color(0xFFE0E0E0), // Warna latar belakang sesuai dengan latar belakang layar + child: const Column( + children: [ + SizedBox(height: 15), // Jarak atas yang lebih besar + ReceiptTearTop(), // Efek kertas struk tersobek di bagian atas + ], + ), + ), + + // Konten struk + Consumer( + builder: (context, receiptProvider, child) { + final state = receiptProvider.state; + return ReceiptBody( + items: state.items, + sourceAccountName: state.sourceAccountName, + destinationAccountName: state.destinationAccountName, + onOpenStoreInfoConfig: _openStoreInfoConfig, + onSelectSourceAccount: () => _selectSourceAccount(), // Memanggil fungsi langsung + onSelectDestinationAccount: () => _selectDestinationAccount(), // Memanggil fungsi langsung + onOpenCustomTextConfig: _openCustomTextConfig, + total: state.total, + onEditItem: (index) => _editItem(index), // Memanggil fungsi langsung + onRemoveItem: (index) { + // Validasi index untuk mencegah error + if (index >= 0 && index < state.items.length) { + // Hapus item dari daftar melalui provider + receiptProvider.removeItem(index); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Gagal menghapus item: Index tidak valid.")), + ); + } + } + }, + onAddItem: () => _addItem(), // Memanggil fungsi langsung + ); + } + ), + + // Background untuk efek kertas struk tersobek di bagian bawah + Container( + width: 360, + color: const Color(0xFFE0E0E0), // Warna latar belakang sesuai dengan latar belakang layar + child: const Column( + children: [ + ReceiptTearBottom(), // Efek kertas struk tersobek di bagian bawah + SizedBox(height: 15), // Jarak bawah yang lebih besar + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/services/account_dialog_service.dart b/lib/services/account_dialog_service.dart new file mode 100644 index 0000000..aa4c19a --- /dev/null +++ b/lib/services/account_dialog_service.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; + +class AccountDialogService { + /// Menampilkan dialog untuk memilih akun sumber (revenue) + static Future?> showSourceAccountDialog( + BuildContext context, + List> accounts, + ) async { + if (accounts.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Daftar akun kosong. Pastikan kredensial sudah diatur dan akun telah dimuat. Klik "Muat Ulang Akun" untuk mencoba lagi.')), + ); + return null; + } + + // Filter akun sumber (revenue) + final revenueAccounts = + accounts.where((account) => account['type'] == 'revenue').toList(); + + if (revenueAccounts.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Tidak ada akun sumber (revenue) yang ditemukan. Klik "Muat Ulang Akun" untuk mencoba lagi atau periksa akun Anda di Firefly III.')), + ); + return null; + } + + return await showDialog?>( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Pilih Akun Sumber'), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: revenueAccounts.length, + itemBuilder: (context, index) { + final account = revenueAccounts[index]; + return ListTile( + title: Text(account['name']), + subtitle: Text(account['type']), + onTap: () => Navigator.of(context).pop(account), + ); + }, + ), + ), + ); + }, + ); + } + + /// Menampilkan dialog untuk memilih akun tujuan (asset) + static Future?> showDestinationAccountDialog( + BuildContext context, + List> accounts, + ) async { + if (accounts.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Daftar akun kosong. Pastikan kredensial sudah diatur dan akun telah dimuat. Klik "Muat Ulang Akun" untuk mencoba lagi.')), + ); + return null; + } + + // Filter akun tujuan (asset) + final assetAccounts = + accounts.where((account) => account['type'] == 'asset').toList(); + + if (assetAccounts.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Tidak ada akun tujuan (asset) yang ditemukan. Klik "Muat Ulang Akun" untuk mencoba lagi atau periksa akun Anda di Firefly III.')), + ); + return null; + } + + return await showDialog?>( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Pilih Akun Tujuan'), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: assetAccounts.length, + itemBuilder: (context, index) { + final account = assetAccounts[index]; + return ListTile( + title: Text(account['name']), + subtitle: Text(account['type']), + onTap: () => Navigator.of(context).pop(account), + ); + }, + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/services/bluetooth_service.dart b/lib/services/bluetooth_service.dart new file mode 100644 index 0000000..d8b5bd6 --- /dev/null +++ b/lib/services/bluetooth_service.dart @@ -0,0 +1,140 @@ +import 'package:bluetooth_print/bluetooth_print.dart'; +import 'package:bluetooth_print/bluetooth_print_model.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:typed_data'; + +class BluetoothService { + late BluetoothPrint _bluetoothPrint; + BluetoothDevice? _connectedDevice; + bool _isConnected = false; + bool _isPrinting = false; + + BluetoothService() { + _bluetoothPrint = BluetoothPrint.instance; + } + + // Getter untuk status koneksi + bool get isConnected => _isConnected; + bool get isPrinting => _isPrinting; + BluetoothDevice? get connectedDevice => _connectedDevice; + + /// Inisialisasi Bluetooth printer + Future initialize() async { + // Memeriksa status koneksi Bluetooth + final isConnected = await _bluetoothPrint.isConnected ?? false; + _isConnected = isConnected; + } + + /// Memuat device bluetooth yang tersimpan + Future loadSavedDevice() async { + final prefs = await SharedPreferences.getInstance(); + final deviceAddress = prefs.getString('bluetooth_device_address'); + final deviceName = prefs.getString('bluetooth_device_name'); + + if (deviceAddress != null && deviceName != null) { + _connectedDevice = BluetoothDevice(); + _connectedDevice!.name = deviceName; + _connectedDevice!.address = deviceAddress; + } + } + + /// Memeriksa status koneksi Bluetooth printer secara real-time + Future checkConnection() async { + try { + final isConnected = await _bluetoothPrint.isConnected ?? false; + _isConnected = isConnected; + return isConnected; + } catch (e) { + return false; + } + } + + /// Menghubungkan ke device bluetooth + Future connectToDevice(BluetoothDevice device) async { + try { + await _bluetoothPrint.connect(device); + _connectedDevice = device; + _isConnected = true; + + // Simpan device ke SharedPreferences + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('bluetooth_device_address', device.address ?? ''); + await prefs.setString('bluetooth_device_name', device.name ?? ''); + + return true; + } catch (e) { + return false; + } + } + + /// Memutuskan koneksi dari device bluetooth + Future disconnect() async { + try { + await _bluetoothPrint.disconnect(); + _isConnected = false; + } catch (e) { + rethrow; + } + } + + /// Mencetak struk ke printer thermal + Future printReceipt(Uint8List data) async { + if (!_isConnected) { + throw Exception('Printer tidak terhubung'); + } + + _isPrinting = true; + try { + await _bluetoothPrint.printRawData(data); + } finally { + _isPrinting = false; + } + } + + /// Mencoba menyambungkan kembali ke printer jika terputus + Future reconnectIfNeeded() async { + // Periksa koneksi printer secara real-time + bool isConnected = await checkConnection(); + + if (!isConnected) { + // Coba sambungkan kembali jika ada device yang tersimpan + if (_connectedDevice != null) { + try { + // Putuskan koneksi yang mungkin tersisa + try { + await disconnect(); + } catch (disconnectError) { + // Tidak ada koneksi yang perlu diputuskan + } + + // Tunggu sebentar sebelum menyambungkan kembali + await Future.delayed(const Duration(milliseconds: 500)); + + // Sambungkan kembali + bool connectResult = await connectToDevice(_connectedDevice!); + + if (!connectResult) { + return false; + } + + // Tunggu sebentar untuk memastikan koneksi stabil + await Future.delayed(const Duration(milliseconds: 500)); + + // Periksa koneksi lagi + isConnected = await checkConnection(); + + return isConnected; + } catch (e) { + return false; + } + } else { + return false; + } + } else { + return true; + } + } + + /// Mendengarkan perubahan status bluetooth + Stream get state => _bluetoothPrint.state; +} \ No newline at end of file diff --git a/lib/services/esc_pos_print_service.dart b/lib/services/esc_pos_print_service.dart index fdd1aeb..96dc2dc 100644 --- a/lib/services/esc_pos_print_service.dart +++ b/lib/services/esc_pos_print_service.dart @@ -7,6 +7,7 @@ import 'package:image/image.dart' as img; import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; /// Service untuk menghasilkan perintah ESC/POS menggunakan flutter_esc_pos_utils class EscPosPrintService { @@ -357,6 +358,77 @@ class EscPosPrintService { return bytes; } + + /// Mencetak struk ke printer thermal + static Future printToThermalPrinter({ + required List items, + required DateTime transactionDate, + required BuildContext context, + required dynamic bluetoothService, // Kita akan sesuaikan tipe ini nanti + }) async { + // Definisikan cashierId dan transactionId di sini karena tidak berubah + final String cashierId = 'KSR001'; + final String transactionId = 'TXN202508200001'; + + print('=== FUNGSI printToThermalPrinter DIPANGGIL ==='); + print('Memulai proses pencetakan struk...'); + print('Jumlah item: ${items.length}'); + print('Tanggal transaksi: ${transactionDate}'); + print('ID kasir: $cashierId'); + print('ID transaksi: $transactionId'); + + try { + print('Menghasilkan byte array ESC/POS menggunakan flutter_esc_pos_utils...'); + print('Jumlah item: ${items.length}'); + print('Tanggal transaksi: ${transactionDate}'); + print('ID kasir: $cashierId'); + print('ID transaksi: $transactionId'); + + // Generate struk dalam format byte array menggunakan EscPosPrintService + final bytes = await generateEscPosBytes( + items: items, + transactionDate: transactionDate, + cashierId: cashierId, + transactionId: transactionId, + ); + + print('Byte array ESC/POS berhasil dihasilkan'); + print('Jumlah byte: ${bytes.length}'); + + // Tampilkan byte array untuk debugging (dalam format hex) + print('Isi byte array (hex):'); + if (bytes.length <= 1000) { // Batasi tampilan untuk mencegah output terlalu panjang + print(bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')); + } else { + print('Terlalu banyak byte untuk ditampilkan (${bytes.length} bytes)'); + } + + // Verifikasi koneksi sebelum mencetak + final isConnectedBeforePrint = await bluetoothService.checkConnection(); + if (!isConnectedBeforePrint) { + print('Printer tidak terhubung saat akan mencetak'); + throw Exception('Printer tidak terhubung saat akan mencetak'); + } + + print('Mengirim byte array ke printer...'); + + try { + // Konversi List ke Uint8List + final Uint8List data = Uint8List.fromList(bytes); + await bluetoothService.printReceipt(data); + print('Perintah cetak berhasil dikirim'); + } catch (printError) { + print('Error saat mengirim perintah cetak ke printer: $printError'); + throw Exception('Gagal mengirim perintah cetak: $printError'); + } + } catch (e, stackTrace) { + print('Error saat mencetak struk: $e'); + print('Stack trace: $stackTrace'); + // Cetak detail error tambahan + print('Tipe error: ${e.runtimeType}'); + throw Exception('Gagal mencetak struk: $e'); + } + } } /// Data class to hold image processing parameters diff --git a/lib/widgets/receipt_speed_dial.dart b/lib/widgets/receipt_speed_dial.dart new file mode 100644 index 0000000..e235022 --- /dev/null +++ b/lib/widgets/receipt_speed_dial.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_speed_dial/flutter_speed_dial.dart'; +import 'package:cashumit/services/bluetooth_service.dart'; + +class ReceiptSpeedDial extends StatelessWidget { + final BluetoothService bluetoothService; + final Future Function() onCheckConnection; + final Future Function() onPrint; + final VoidCallback onSettings; + final Future Function() onReloadAccounts; + final bool hasItems; + final bool hasSourceAccount; + final bool hasDestinationAccount; + final Future Function() onSendToFirefly; + + const ReceiptSpeedDial({ + Key? key, + required this.bluetoothService, + required this.onCheckConnection, + required this.onPrint, + required this.onSettings, + required this.onReloadAccounts, + required this.hasItems, + required this.hasSourceAccount, + required this.hasDestinationAccount, + required this.onSendToFirefly, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SpeedDial( + icon: Icons.menu, + activeIcon: Icons.close, + spacing: 3, + spaceBetweenChildren: 4, + children: [ + SpeedDialChild( + child: const Icon(Icons.send), + label: 'Kirim ke Firefly', + onTap: hasItems && hasSourceAccount && hasDestinationAccount + ? onSendToFirefly + : () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Pilih akun sumber dan tujuan terlebih dahulu'), + duration: Duration(seconds: 2), + ), + ); + }, + backgroundColor: hasItems && + hasSourceAccount && + hasDestinationAccount + ? Colors.blue + : Colors.grey, + ), + SpeedDialChild( + child: const Icon(Icons.refresh), + label: 'Muat Ulang Akun', + onTap: onReloadAccounts, + backgroundColor: Colors.orange, + ), + SpeedDialChild( + child: const Icon(Icons.settings), + label: 'Pengaturan', + onTap: onSettings, + backgroundColor: Colors.green, + ), + SpeedDialChild( + child: bluetoothService.isPrinting + ? const CircularProgressIndicator(color: Colors.white, strokeWidth: 2) + : const Icon(Icons.receipt), + label: 'Cetak Struk', + onTap: bluetoothService.isPrinting + ? null + : () async { + // Periksa koneksi secara real-time + final isConnected = await onCheckConnection(); + if (isConnected) { + onPrint(); + } else { + // Coba sambungkan kembali jika ada device yang tersimpan + if (bluetoothService.connectedDevice != null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Mencoba menyambungkan ke printer...')), + ); + try { + bool connectResult = await bluetoothService.connectToDevice(bluetoothService.connectedDevice!); + if (!connectResult) { + throw Exception('Gagal menyambungkan ke printer'); + } + // Tunggu sebentar untuk memastikan koneksi stabil + await Future.delayed(const Duration(milliseconds: 500)); + // Periksa koneksi lagi + final isConnectedAfterConnect = await onCheckConnection(); + if (isConnectedAfterConnect) { + onPrint(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Gagal menyambungkan ke printer')), + ); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gagal menyambungkan ke printer: $e')), + ); + } + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Hubungkan printer terlebih dahulu')), + ); + } + } + }, + backgroundColor: Colors.purple, + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/receipt_total.dart b/lib/widgets/receipt_total.dart new file mode 100644 index 0000000..17cf3f8 --- /dev/null +++ b/lib/widgets/receipt_total.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:cashumit/extensions/double_extensions.dart'; + +class ReceiptTotal extends StatelessWidget { + final double total; + + const ReceiptTotal({Key? key, required this.total}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(8.0), + color: Colors.white, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Expanded( + flex: 4, + child: Text( + 'TOTAL:', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + Expanded( + flex: 4, + child: Text( + total.toRupiah(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.right, + ), + ), + ], + ), + ], + ), + ); + } +} \ No newline at end of file