1222 lines
43 KiB
Dart
1222 lines
43 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:cashumit/models/receipt_item.dart';
|
|
import 'package:cashumit/screens/add_item_screen.dart';
|
|
import 'package:cashumit/screens/settings_screen.dart';
|
|
import 'package:cashumit/screens/printer_setup_screen.dart';
|
|
import 'package:cashumit/services/pdf_export_service.dart';
|
|
import 'package:cashumit/services/firefly_api_service.dart';
|
|
import 'package:bluetooth_print/bluetooth_print.dart';
|
|
import 'package:bluetooth_print/bluetooth_print_model.dart';
|
|
|
|
class ReceiptScreen extends StatefulWidget {
|
|
const ReceiptScreen({super.key});
|
|
|
|
@override
|
|
State<ReceiptScreen> createState() => _ReceiptScreenState();
|
|
}
|
|
|
|
class _ReceiptScreenState extends State<ReceiptScreen> {
|
|
List<ReceiptItem> items = [
|
|
ReceiptItem(description: 'Apel', quantity: 3, price: 5000),
|
|
ReceiptItem(description: 'Roti', quantity: 2, price: 12000),
|
|
ReceiptItem(description: 'Susu', quantity: 1, price: 15000),
|
|
];
|
|
|
|
final String storeName = 'TOKO SEMBAKO MURAH';
|
|
final String storeAddress = 'Jl. Merdeka No. 123';
|
|
final String storePhone = 'Telp: 021-12345678';
|
|
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
|
|
BluetoothPrint bluetoothPrint = BluetoothPrint.instance;
|
|
bool _bluetoothConnected = false;
|
|
BluetoothDevice? _bluetoothDevice;
|
|
bool _bluetoothScanning = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_transactionDate = DateTime.now();
|
|
_loadCredentialsAndAccounts();
|
|
_initBluetooth();
|
|
_loadSavedBluetoothDevice();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_sourceAccountController.dispose();
|
|
_destinationAccountController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String? _fireflyUrl;
|
|
String? _accessToken;
|
|
|
|
/// Memuat kredensial dari shared preferences dan kemudian memuat akun.
|
|
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.')),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_fireflyUrl = url;
|
|
_accessToken = token;
|
|
});
|
|
|
|
// Jika kredensial ada, lanjutkan untuk memuat akun
|
|
_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;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Menyimpan device bluetooth yang terhubung
|
|
Future<void> _saveBluetoothDevice(BluetoothDevice device) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('bluetooth_device_address', device.address ?? '');
|
|
await prefs.setString('bluetooth_device_name', device.name ?? '');
|
|
}
|
|
|
|
/// Memuat daftar akun sumber (revenue) dan tujuan (asset) dari API.
|
|
Future<void> _loadAccounts() async {
|
|
if (_fireflyUrl == null || _accessToken == null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 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;
|
|
});
|
|
} 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;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Memeriksa ulang status koneksi bluetooth
|
|
Future<void> _checkBluetoothStatus() async {
|
|
final isConnected = await bluetoothPrint.isConnected ?? false;
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_bluetoothConnected = isConnected;
|
|
});
|
|
|
|
if (isConnected) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Printer terhubung')),
|
|
);
|
|
} else {
|
|
// Jika ada device yang sudah dipilih sebelumnya, coba sambungkan kembali
|
|
if (_bluetoothDevice != null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Mencoba menyambungkan kembali ke printer...')),
|
|
);
|
|
_reconnectToBluetoothDevice();
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Printer tidak terhubung. Silakan sambungkan melalui menu Setup Printer atau ikon Bluetooth di atas.')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Menyambungkan kembali ke printer bluetooth yang sudah dipilih
|
|
Future<void> _reconnectToBluetoothDevice() async {
|
|
if (_bluetoothDevice != null) {
|
|
try {
|
|
await bluetoothPrint.connect(_bluetoothDevice!);
|
|
if (mounted) {
|
|
setState(() {
|
|
_bluetoothConnected = true;
|
|
});
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Berhasil terhubung kembali ke printer')),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Gagal menyambungkan kembali ke printer: $e')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Scan perangkat Bluetooth
|
|
Future<void> _scanBluetoothDevices() async {
|
|
setState(() {
|
|
_bluetoothScanning = true;
|
|
});
|
|
|
|
try {
|
|
// Mulai scan perangkat Bluetooth
|
|
await bluetoothPrint.startScan(timeout: const Duration(seconds: 4));
|
|
|
|
if (mounted) {
|
|
final selectedDevice = await showDialog<BluetoothDevice?>(
|
|
context: context,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
title: const Text('Pilih Perangkat Printer'),
|
|
content: StreamBuilder<List<BluetoothDevice>>(
|
|
stream: bluetoothPrint.scanResults,
|
|
initialData: const [],
|
|
builder: (c, snapshot) {
|
|
if (snapshot.data!.isEmpty) {
|
|
return const Text('Tidak ada perangkat ditemukan');
|
|
}
|
|
|
|
return SizedBox(
|
|
width: double.maxFinite,
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: snapshot.data!.length,
|
|
itemBuilder: (context, index) {
|
|
final device = snapshot.data![index];
|
|
return ListTile(
|
|
title: Text(device.name ?? 'Unknown Device'),
|
|
subtitle: Text(device.address ?? ''),
|
|
onTap: () {
|
|
Navigator.of(context).pop(device);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
if (selectedDevice != null) {
|
|
setState(() {
|
|
_bluetoothDevice = selectedDevice;
|
|
});
|
|
|
|
// Simpan device yang dipilih
|
|
await _saveBluetoothDevice(selectedDevice);
|
|
|
|
// Hubungkan ke perangkat yang dipilih
|
|
await bluetoothPrint.connect(selectedDevice);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Gagal memindai perangkat: $e')),
|
|
);
|
|
}
|
|
} finally {
|
|
setState(() {
|
|
_bluetoothScanning = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Cetak struk ke printer thermal
|
|
Future<void> _printToThermalPrinter() async {
|
|
print('=== MULAI MENCETAK KE PRINTER THERMAL ===');
|
|
|
|
// Periksa koneksi printer
|
|
final isConnected = await bluetoothPrint.isConnected ?? false;
|
|
|
|
if (!isConnected) {
|
|
print('ERROR: Printer tidak terhubung');
|
|
if (mounted) {
|
|
// Coba sambungkan kembali jika ada device yang tersimpan
|
|
if (_bluetoothDevice != null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Mencoba menyambungkan kembali ke printer...')),
|
|
);
|
|
try {
|
|
await bluetoothPrint.connect(_bluetoothDevice!);
|
|
// Perbarui status koneksi
|
|
setState(() {
|
|
_bluetoothConnected = true;
|
|
});
|
|
} catch (e) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Gagal menyambungkan kembali ke printer: $e')),
|
|
);
|
|
return;
|
|
}
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Harap hubungkan printer terlebih dahulu')),
|
|
);
|
|
return;
|
|
}
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
print('Printer terhubung, mencoba mencetak...');
|
|
print('Device address: ${_bluetoothDevice?.address}');
|
|
print('Device name: ${_bluetoothDevice?.name}');
|
|
|
|
try {
|
|
// Konfigurasi untuk mencetak struk
|
|
Map<String, dynamic> config = {};
|
|
|
|
// Data yang akan dicetak
|
|
List<LineText> list = [];
|
|
|
|
print('Membuat data struk untuk dicetak...');
|
|
|
|
// Header struk
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: storeName, weight: 1, align: LineText.ALIGN_CENTER, fontZoom: 2, linefeed: 1));
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: storeAddress, align: LineText.ALIGN_CENTER, linefeed: 1));
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: storePhone, align: LineText.ALIGN_CENTER, linefeed: 1));
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: '--------------------------------', align: LineText.ALIGN_CENTER, linefeed: 1));
|
|
|
|
// Info transaksi
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: 'TANGGAL: ${_transactionDate.day.toString().padLeft(2, '0')}/${_transactionDate.month.toString().padLeft(2, '0')}/${_transactionDate.year}', linefeed: 1));
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: 'KASIR: $cashierId', linefeed: 1));
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: 'NOTA: $transactionId', linefeed: 1));
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: '--------------------------------', align: LineText.ALIGN_CENTER, linefeed: 1));
|
|
|
|
// Header item
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: 'ITEM Q HARGA TOTAL', weight: 1, linefeed: 1));
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: '--------------------------------', align: LineText.ALIGN_CENTER, linefeed: 1));
|
|
|
|
// Item list
|
|
for (var item in items) {
|
|
// Nama item (potong jika terlalu panjang)
|
|
String itemName = item.description.length > 12
|
|
? item.description.substring(0, 12)
|
|
: item.description.padRight(12);
|
|
|
|
// Quantity (3 karakter)
|
|
String qty = item.quantity.toString().padLeft(3);
|
|
|
|
// Harga (8 karakter)
|
|
String price = item.price.toStringAsFixed(0).padLeft(8);
|
|
|
|
// Total (8 karakter)
|
|
String total = item.total.toStringAsFixed(0).padLeft(8);
|
|
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: '$itemName $qty $price $total', linefeed: 1));
|
|
}
|
|
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: '--------------------------------', align: LineText.ALIGN_CENTER, linefeed: 1));
|
|
|
|
// Total
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: 'TOTAL: ${_calculateTotal().toStringAsFixed(0).padLeft(8)}', weight: 1, linefeed: 1));
|
|
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: '--------------------------------', align: LineText.ALIGN_CENTER, linefeed: 1));
|
|
|
|
// Footer
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: '*** TERIMA KASIH ***', align: LineText.ALIGN_CENTER, fontZoom: 1, linefeed: 1));
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: 'Barang yang sudah dibeli', align: LineText.ALIGN_CENTER, linefeed: 1));
|
|
list.add(LineText(type: LineText.TYPE_TEXT, content: 'tidak dapat dikembalikan/ditukar', align: LineText.ALIGN_CENTER, linefeed: 1));
|
|
|
|
// Garis kosong di akhir
|
|
list.add(LineText(linefeed: 3));
|
|
|
|
print('Data struk berhasil dibuat, mengirim ke printer...');
|
|
print('Jumlah baris: ${list.length}');
|
|
|
|
// Kirim perintah cetak ke printer
|
|
await bluetoothPrint.printReceipt(config, list);
|
|
|
|
print('Perintah cetak dikirim ke printer');
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Struk berhasil dicetak')),
|
|
);
|
|
}
|
|
} catch (e, stackTrace) {
|
|
print('ERROR saat mencetak struk: $e');
|
|
print('Stack trace: $stackTrace');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Gagal mencetak struk: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
print('=== SELESAI MENCETAK KE PRINTER THERMAL ===');
|
|
}
|
|
|
|
void _addItem() async {
|
|
final newItem = await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const AddItemScreen()),
|
|
);
|
|
|
|
if (newItem != null) {
|
|
setState(() {
|
|
items.add(newItem);
|
|
});
|
|
}
|
|
}
|
|
|
|
void _editItem(int index) async {
|
|
final editedItem = await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => AddItemScreen.fromItem(items[index]),
|
|
),
|
|
);
|
|
|
|
if (editedItem != null) {
|
|
setState(() {
|
|
items[index] = editedItem;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _removeItem(int index) {
|
|
setState(() {
|
|
items.removeAt(index);
|
|
});
|
|
}
|
|
|
|
Future<void> _printReceipt() async {
|
|
// Generate PDF
|
|
final pdfPath = await PdfExportService.generateReceiptPdf(
|
|
items,
|
|
storeName,
|
|
storeAddress,
|
|
storePhone,
|
|
);
|
|
|
|
if (pdfPath != null) {
|
|
// Menampilkan pesan sukses
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('PDF berhasil dibuat')),
|
|
);
|
|
}
|
|
|
|
// Membuka file PDF
|
|
await PdfExportService.openPdf(pdfPath);
|
|
} else {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Gagal membuat file PDF')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _selectDate(BuildContext context) async {
|
|
final DateTime? picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: _transactionDate,
|
|
firstDate: DateTime(2020),
|
|
lastDate: DateTime(2030),
|
|
);
|
|
|
|
if (picked != null && picked != _transactionDate) {
|
|
setState(() {
|
|
_transactionDate = picked;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _selectSourceAccount() async {
|
|
if (_accounts.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Daftar akun belum dimuat')),
|
|
);
|
|
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: _accounts.length,
|
|
itemBuilder: (context, index) {
|
|
final account = _accounts[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'];
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _selectDestinationAccount() async {
|
|
if (_accounts.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Daftar akun belum dimuat')),
|
|
);
|
|
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: _accounts.length,
|
|
itemBuilder: (context, index) {
|
|
final account = _accounts[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'];
|
|
});
|
|
}
|
|
}
|
|
|
|
// Method to handle manual input of source account
|
|
void _onSourceAccountChanged(String value) {
|
|
// Clear selected account if user is typing
|
|
if (_sourceAccountName != value) {
|
|
setState(() {
|
|
_sourceAccountId = null;
|
|
_sourceAccountName = value;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Method to handle manual input of destination account
|
|
void _onDestinationAccountChanged(String value) {
|
|
// Clear selected account if user is typing
|
|
if (_destinationAccountName != value) {
|
|
setState(() {
|
|
_destinationAccountId = null;
|
|
_destinationAccountName = value;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Method to find account ID by name when user inputs manually
|
|
String? _findAccountIdByName(String name) {
|
|
if (name.isEmpty) return null;
|
|
|
|
try {
|
|
final account = _accounts.firstWhere(
|
|
(account) => account['name'].toString().toLowerCase() == name.toLowerCase(),
|
|
);
|
|
|
|
return account['id'] as String?;
|
|
} catch (e) {
|
|
// Jika tidak ditemukan, kembalikan null
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> _sendToFirefly() async {
|
|
if (items.isEmpty) {
|
|
if (mounted) {
|
|
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!);
|
|
// Jika tidak ditemukan, coba cari dengan pendekatan yang lebih fleksibel
|
|
if (_sourceAccountId == null) {
|
|
for (var account in _accounts) {
|
|
if (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!);
|
|
// Jika tidak ditemukan, coba cari dengan pendekatan yang lebih fleksibel
|
|
if (_destinationAccountId == null) {
|
|
for (var account in _accounts) {
|
|
if (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) {
|
|
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) {
|
|
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;
|
|
|
|
for (var account in _accounts) {
|
|
if (account['id'].toString() == _sourceAccountId) {
|
|
sourceAccountExists = true;
|
|
}
|
|
if (account['id'].toString() == _destinationAccountId) {
|
|
destinationAccountExists = true;
|
|
}
|
|
}
|
|
|
|
if (!sourceAccountExists) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Akun sumber tidak ditemukan di Firefly III. Silakan pilih akun yang valid.')),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!destinationAccountExists) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Akun tujuan tidak ditemukan di Firefly III. Silakan pilih akun yang valid.')),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final total = _calculateTotal();
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Mengirim transaksi ke Firefly III...')),
|
|
);
|
|
}
|
|
|
|
print('=== MULAI MENGIRIM TRANSAKSI ===');
|
|
print('Jumlah item: ${items.length}');
|
|
print('Total: $total');
|
|
print('Source Account ID: $_sourceAccountId');
|
|
print('Destination Account ID: $_destinationAccountId');
|
|
print('Transaction Date: $_transactionDate');
|
|
for (var i = 0; i < items.length; i++) {
|
|
var item = items[i];
|
|
print(' Item ${i+1}: ${item.description} - ${item.quantity} x ${item.price} = ${item.total}');
|
|
}
|
|
|
|
bool success = false;
|
|
if (_fireflyUrl != null && _accessToken != null) {
|
|
success = await FireflyApiService.submitDummyTransaction(
|
|
baseUrl: _fireflyUrl!,
|
|
accessToken: _accessToken!,
|
|
sourceId: _sourceAccountId!,
|
|
destinationId: _destinationAccountId!,
|
|
type: 'deposit',
|
|
description: 'Transaksi Struk Belanja',
|
|
date: '${_transactionDate.year}-${_transactionDate.month.toString().padLeft(2, '0')}-${_transactionDate.day.toString().padLeft(2, '0')}',
|
|
amount: total.toStringAsFixed(2),
|
|
);
|
|
}
|
|
|
|
if (mounted) {
|
|
if (success) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Transaksi berhasil dikirim ke Firefly III')),
|
|
);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Gagal mengirim transaksi ke Firefly III. Periksa log untuk detail kesalahan.')),
|
|
);
|
|
}
|
|
}
|
|
print('=== SELESAI MENGIRIM TRANSAKSI ===');
|
|
}
|
|
|
|
void _openSettings() {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
|
);
|
|
}
|
|
|
|
void _openPrinterSetup() {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const PrinterSetupScreen()),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Gunakan font Courier Prime untuk menyerupai font struk fisik
|
|
final courierPrime = GoogleFonts.courierPrime(
|
|
textStyle: const TextStyle(
|
|
fontSize: 14,
|
|
height: 1.2,
|
|
),
|
|
);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Struk Belanja'),
|
|
centerTitle: true,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.settings),
|
|
onPressed: _openSettings,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.print),
|
|
onPressed: _printReceipt,
|
|
),
|
|
IconButton(
|
|
icon: Icon(_bluetoothConnected ? Icons.bluetooth_connected : Icons.bluetooth),
|
|
onPressed: _scanBluetoothDevices,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: _checkBluetoothStatus,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.cloud_upload),
|
|
onPressed: _sendToFirefly,
|
|
),
|
|
],
|
|
),
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
// Receipt paper style container - desain ulang untuk menyerupai struk fisik
|
|
Container(
|
|
width: 320, // Lebar tetap untuk menyerupai struk fisik
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.black),
|
|
color: Colors.white,
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8.0), // Kurangi padding
|
|
child: Column(
|
|
children: [
|
|
// Header struk
|
|
Text(
|
|
storeName,
|
|
style: courierPrime.copyWith(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(storeAddress, style: courierPrime, textAlign: TextAlign.center),
|
|
Text(storePhone, style: courierPrime, textAlign: TextAlign.center),
|
|
const SizedBox(height: 4),
|
|
const Divider(thickness: 1, height: 1, color: Colors.black),
|
|
const SizedBox(height: 4),
|
|
|
|
// Info transaksi tambahan
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('TANGGAL:', style: courierPrime),
|
|
Text(
|
|
'${_transactionDate.day.toString().padLeft(2, '0')}/${_transactionDate.month.toString().padLeft(2, '0')}/${_transactionDate.year}',
|
|
style: courierPrime,
|
|
),
|
|
],
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('KASIR:', style: courierPrime),
|
|
Text(cashierId, style: courierPrime),
|
|
],
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('NOTA:', style: courierPrime),
|
|
Text(transactionId, style: courierPrime),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
const Divider(thickness: 1, height: 1, color: Colors.black),
|
|
const SizedBox(height: 4),
|
|
|
|
// Item list header
|
|
const Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(
|
|
flex: 4,
|
|
child: Text(
|
|
'ITEM',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 1,
|
|
child: Text(
|
|
'Q',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
'@HARGA',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
textAlign: TextAlign.right,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
'TOTAL',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
textAlign: TextAlign.right,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
const Divider(thickness: 1, height: 1, color: Colors.black),
|
|
const SizedBox(height: 4),
|
|
|
|
// Item list
|
|
...items.map((item) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 2.0), // Kurangi jarak antar item
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(
|
|
flex: 4,
|
|
child: Text(
|
|
item.description,
|
|
style: courierPrime,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 1,
|
|
child: Text(
|
|
item.quantity.toString(),
|
|
textAlign: TextAlign.center,
|
|
style: courierPrime,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
item.price.toStringAsFixed(0),
|
|
textAlign: TextAlign.right,
|
|
style: courierPrime,
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
item.total.toStringAsFixed(0),
|
|
textAlign: TextAlign.right,
|
|
style: courierPrime,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
|
|
const SizedBox(height: 4),
|
|
const Divider(thickness: 1, height: 1, color: Colors.black),
|
|
const SizedBox(height: 4),
|
|
|
|
// Total
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'TOTAL:',
|
|
style: courierPrime.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
_calculateTotal().toStringAsFixed(0),
|
|
style: courierPrime.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'*** TERIMA KASIH ***',
|
|
style: courierPrime.copyWith(fontWeight: FontWeight.bold),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
Text(
|
|
'Barang yang sudah dibeli tidak dapat',
|
|
style: courierPrime,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
Text(
|
|
'dikembalikan/ditukar',
|
|
style: courierPrime,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Account selection section - tetap di luar area struk
|
|
Container(
|
|
width: 320, // Sesuaikan lebar dengan area struk
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
borderRadius: BorderRadius.circular(4),
|
|
color: Colors.grey.shade50,
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Pengaturan Transaksi',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
|
|
// Source account
|
|
const Text(
|
|
'Akun Sumber:',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
_sourceAccountId != null
|
|
? ListTile(
|
|
title: Text(_sourceAccountName ?? ''),
|
|
contentPadding: EdgeInsets.zero,
|
|
dense: true,
|
|
trailing: const Icon(Icons.edit, size: 18),
|
|
onTap: _selectSourceAccount,
|
|
)
|
|
: TextField(
|
|
controller: _sourceAccountController,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Ketik nama akun atau pilih dari daftar',
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 8),
|
|
isDense: true,
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onChanged: _onSourceAccountChanged,
|
|
),
|
|
if (_sourceAccountId == null)
|
|
ElevatedButton(
|
|
onPressed: _selectSourceAccount,
|
|
child: const Text('Pilih Akun Sumber'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
|
|
// Destination account
|
|
const Text(
|
|
'Akun Tujuan:',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
_destinationAccountId != null
|
|
? ListTile(
|
|
title: Text(_destinationAccountName ?? ''),
|
|
contentPadding: EdgeInsets.zero,
|
|
dense: true,
|
|
trailing: const Icon(Icons.edit, size: 18),
|
|
onTap: _selectDestinationAccount,
|
|
)
|
|
: TextField(
|
|
controller: _destinationAccountController,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Ketik nama akun atau pilih dari daftar',
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 8),
|
|
isDense: true,
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onChanged: _onDestinationAccountChanged,
|
|
),
|
|
if (_destinationAccountId == null)
|
|
ElevatedButton(
|
|
onPressed: _selectDestinationAccount,
|
|
child: const Text('Pilih Akun Tujuan'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Action buttons
|
|
SizedBox(
|
|
width: 320, // Sesuaikan lebar dengan area struk
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
ElevatedButton(
|
|
onPressed: _addItem,
|
|
child: const Text('Tambah Item'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: items.isNotEmpty ? _sendToFirefly : null,
|
|
child: const Text('Kirim ke Firefly'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Additional action buttons
|
|
SizedBox(
|
|
width: 320, // Sesuaikan lebar dengan area struk
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: ElevatedButton(
|
|
onPressed: _openPrinterSetup,
|
|
child: const Text('Setup Printer', textAlign: TextAlign.center),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: ElevatedButton(
|
|
onPressed: _printReceipt,
|
|
child: const Text('Cetak PDF', textAlign: TextAlign.center),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: ElevatedButton(
|
|
onPressed: _bluetoothConnected ? _printToThermalPrinter : null,
|
|
child: const Text('Cetak Struk', textAlign: TextAlign.center),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
if (!_bluetoothConnected)
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: 8.0),
|
|
child: Text(
|
|
'⚠️ Printer tidak terhubung. Hubungkan printer melalui menu Setup Printer atau ikon Bluetooth di atas.',
|
|
style: TextStyle(
|
|
color: Colors.orange,
|
|
fontSize: 12,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
|
|
if (_sourceAccountId == null || _destinationAccountId == null)
|
|
const Padding(
|
|
padding: EdgeInsets.only(top: 8.0),
|
|
child: Text(
|
|
'⚠️ Pilih akun sumber dan tujuan untuk mengaktifkan tombol kirim',
|
|
style: TextStyle(
|
|
color: Colors.orange,
|
|
fontSize: 12,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
double _calculateTotal() {
|
|
return items.fold(0.0, (sum, item) => sum + item.total);
|
|
}
|
|
} |