cashumit/lib/screens/receipt_screen.dart

1382 lines
43 KiB
Dart

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 '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 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
class ReceiptScreen extends StatefulWidget {
const ReceiptScreen({super.key});
@override
State<ReceiptScreen> createState() => _ReceiptScreenState();
}
class _ReceiptScreenState extends State<ReceiptScreen> {
List<ReceiptItem> items = [];
final String cashierId = 'KSR001';
final String transactionId = 'TXN202508200001';
// Fields for transaction settings directly in main UI
late DateTime _transactionDate;
List<Map<String, dynamic>> _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<void> _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<void> _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<bool> _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<void> _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 = <Map<String, dynamic>>[];
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<void> _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<void> _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<int> 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<ReceiptItem>(
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<ReceiptItem>(
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<bool?> yang menunjukkan apakah pengguna mengkonfirmasi penghapusan.
/// - true: Pengguna mengkonfirmasi.
/// - false: Pengguna membatalkan.
/// - null: Dialog ditutup tanpa memilih opsi.
Future<bool?> _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<bool>(
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<void> _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<Map<String, dynamic>?>(
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<void> _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<Map<String, dynamic>?>(
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<void> _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<String, dynamic>? sourceAccountDetails;
Map<String, dynamic>? 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<void> _openStoreInfoConfig() async {
final result = await showDialog<bool>(
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<void> _openCustomTextConfig() async {
final result = await showDialog<bool>(
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);
}
}