1382 lines
43 KiB
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);
|
|
}
|
|
}
|