cashumit/lib/features/transaction_screen.dart

828 lines
25 KiB
Dart

import 'package:flutter/material.dart';
import 'package:cashumit/models/item.dart';
import 'package:cashumit/models/transaction.dart';
import 'package:cashumit/services/print_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';
import 'package:cashumit/utils/currency_format.dart';
import 'package:shared_preferences/shared_preferences.dart';
class TransactionScreen extends StatefulWidget {
final List<Item> items;
const TransactionScreen({
super.key,
required this.items,
});
@override
State<TransactionScreen> createState() => _TransactionScreenState();
}
class _TransactionScreenState extends State<TransactionScreen> {
final BluetoothPrint bluetoothPrint = BluetoothPrint.instance;
List<BluetoothDevice> _devices = [];
BluetoothDevice? _selectedDevice;
bool _isScanning = false;
final List<Map<String, dynamic>> _cart = [];
int _total = 0;
String _paymentMethod = 'Tunai';
final TextEditingController _searchController = TextEditingController();
List<Item> _filteredItems = [];
// Transaction settings directly in this screen
late DateTime _transactionDate;
List<Map<String, dynamic>> _accounts = [];
String? _sourceAccountId;
String? _sourceAccountName;
String? _destinationAccountId;
String? _destinationAccountName;
bool _isLoadingAccounts = false;
// Controllers for manual account input
final TextEditingController _sourceAccountController = TextEditingController();
final TextEditingController _destinationAccountController = TextEditingController();
String? _fireflyUrl;
String? _accessToken;
@override
void initState() {
super.initState();
_filteredItems = widget.items;
_transactionDate = DateTime.now();
_startScan();
_loadCredentialsAndAccounts();
}
@override
void dispose() {
_sourceAccountController.dispose();
_destinationAccountController.dispose();
super.dispose();
}
/// 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 daftar akun sumber (revenue) dan tujuan (asset) dari API.
Future<void> _loadAccounts() async {
if (_fireflyUrl == null || _accessToken == null) {
return;
}
setState(() {
_isLoadingAccounts = true;
});
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;
_isLoadingAccounts = false;
});
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Gagal memuat akun: $error')),
);
}
setState(() {
_isLoadingAccounts = false;
});
}
}
void _startScan() {
setState(() {
_isScanning = true;
});
bluetoothPrint.startScan(timeout: const Duration(seconds: 4));
bluetoothPrint.scanResults.listen((devices) {
if (mounted) {
setState(() {
_devices = devices;
_isScanning = false;
});
}
});
}
void _addToCart(Item item) {
setState(() {
// Cek apakah item sudah ada di cart
final existingItemIndex =
_cart.indexWhere((cartItem) => cartItem['item'].id == item.id);
if (existingItemIndex != -1) {
// Jika sudah ada, tambahkan quantity
_cart[existingItemIndex]['quantity']++;
} else {
// Jika belum ada, tambahkan sebagai item baru
_cart.add({
'item': item,
'quantity': 1,
});
}
_calculateTotal();
});
}
void _removeFromCart(int index) {
setState(() {
_cart.removeAt(index);
_calculateTotal();
});
}
void _updateQuantity(int index, int quantity) {
if (quantity <= 0) {
_removeFromCart(index);
return;
}
setState(() {
_cart[index]['quantity'] = quantity;
_calculateTotal();
});
}
void _calculateTotal() {
_total = _cart.fold(
0, (sum, item) => sum + (item['item'].price * item['quantity']) as int);
}
void _searchItems(String query) {
if (query.isEmpty) {
setState(() {
_filteredItems = widget.items;
});
return;
}
final filtered = widget.items
.where((item) =>
item.name.toLowerCase().contains(query.toLowerCase()) ||
item.id.toLowerCase().contains(query.toLowerCase()))
.toList();
setState(() {
_filteredItems = filtered;
});
}
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) {
if (mounted) {
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) {
if (mounted) {
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;
final account = _accounts.firstWhere(
(account) => account['name'].toLowerCase() == name.toLowerCase(),
orElse: () => {},
);
return account.isEmpty ? null : account['id'];
}
Future<void> _completeTransaction() async {
if (_cart.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Keranjang masih kosong!')),
);
}
return;
}
if (_selectedDevice == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Pilih printer terlebih dahulu!')),
);
}
return;
}
// Cek apakah user memasukkan akun secara manual
if (_sourceAccountId == null && _sourceAccountName != null && _sourceAccountName!.isNotEmpty) {
_sourceAccountId = _findAccountIdByName(_sourceAccountName!);
}
if (_destinationAccountId == null && _destinationAccountName != null && _destinationAccountName!.isNotEmpty) {
_destinationAccountId = _findAccountIdByName(_destinationAccountName!);
}
// Validasi pengaturan transaksi
if (_sourceAccountId == null || _destinationAccountId == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Harap pilih atau masukkan akun sumber dan tujuan yang valid!')),
);
}
return;
}
if (_sourceAccountId == _destinationAccountId) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Akun sumber dan tujuan tidak boleh sama!')),
);
}
return;
}
// Buat objek transaksi
final transaction = Transaction(
id: DateTime.now().millisecondsSinceEpoch.toString(),
items: _cart
.map((cartItem) => TransactionItem(
itemId: cartItem['item'].id,
name: cartItem['item'].name,
price: cartItem['item'].price,
quantity: cartItem['quantity'],
))
.toList(),
total: _total,
timestamp: DateTime.now(),
paymentMethod: _paymentMethod,
);
// Tampilkan dialog konfirmasi
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Konfirmasi Transaksi'),
content: Text(
'Total: Rp ${CurrencyFormat.formatRupiahWithoutSymbol(_total)}\n'
'Metode Bayar: $_paymentMethod\n'
'Tanggal: ${_transactionDate.day}/${_transactionDate.month}/${_transactionDate.year}\n'
'Sumber: $_sourceAccountName\n'
'Tujuan: $_destinationAccountName'
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Batal'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Bayar'),
),
],
),
);
if (confirmed != true) return;
// Cetak struk
final printService = PrintService();
final printed = await printService.printTransaction(
transaction,
'TOKO SEMBAKO MURAH',
'Jl. Merdeka No. 123, Jakarta',
);
if (!printed) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gagal mencetak struk!')),
);
}
return;
}
// Kirim ke FireFly III dengan pengaturan transaksi
String? transactionId;
if (_fireflyUrl != null && _accessToken != null) {
transactionId = await FireflyApiService.submitDummyTransaction(
baseUrl: _fireflyUrl!,
accessToken: _accessToken!,
sourceId: _sourceAccountId!,
destinationId: _destinationAccountId!,
type: 'deposit',
description: 'Transaksi dari Aplikasi Cashumit',
date: '${_transactionDate.year}-${_transactionDate.month.toString().padLeft(2, '0')}-${_transactionDate.day.toString().padLeft(2, '0')}',
amount: (_total / 1000).toStringAsFixed(2), // Mengonversi ke satuan mata uang
);
}
final bool synced = transactionId != null;
// Tampilkan hasil
if (mounted) {
if (synced) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Transaksi berhasil!')),
);
// Reset keranjang
setState(() {
_cart.clear();
_total = 0;
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Gagal menyinkronkan dengan FireFly III!')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Aplikasi Kasir'),
actions: [
IconButton(
onPressed: _loadAccounts,
icon: _isLoadingAccounts
? const CircularProgressIndicator()
: const Icon(Icons.refresh),
),
IconButton(
onPressed: _startScan,
icon: _isScanning
? const CircularProgressIndicator()
: const Icon(Icons.bluetooth),
),
],
),
body: Column(
children: [
// Pencarian item
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
labelText: 'Cari barang...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: _searchItems,
),
),
// Dropdown printer
if (_devices.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: DropdownButton<BluetoothDevice>(
hint: const Text('Pilih Printer'),
value: _selectedDevice,
items: _devices
.map((device) => DropdownMenuItem(
value: device,
child: Text(device.name ?? device.address ?? '-'),
))
.toList(),
onChanged: (device) {
setState(() {
_selectedDevice = device;
});
},
isExpanded: true,
),
),
// Dropdown metode pembayaran
Padding(
padding: const EdgeInsets.all(8.0),
child: DropdownButton<String>(
value: _paymentMethod,
items: ['Tunai', 'Debit', 'Kredit', 'QRIS']
.map((method) => DropdownMenuItem(
value: method,
child: Text(method),
))
.toList(),
onChanged: (method) {
setState(() {
_paymentMethod = method!;
});
},
isExpanded: true,
),
),
// Transaction settings directly in this screen
Card(
margin: const EdgeInsets.all(8.0),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Pengaturan Transaksi:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
// Date picker
const Text('Tanggal Transaksi:'),
ListTile(
title: Text(
'${_transactionDate.day}/${_transactionDate.month}/${_transactionDate.year}',
),
trailing: const Icon(Icons.calendar_today),
onTap: () => _selectDate(context),
),
const SizedBox(height: 8),
// Source account
const Text('Akun Sumber:'),
_sourceAccountId != null
? Card(
child: ListTile(
title: Text(_sourceAccountName ?? ''),
trailing: const Icon(Icons.edit),
onTap: _selectSourceAccount,
),
)
: Column(
children: [
TextField(
controller: _sourceAccountController,
decoration: const InputDecoration(
labelText: 'Nama Akun Sumber',
hintText: 'Ketik nama akun atau pilih dari daftar',
),
onChanged: _onSourceAccountChanged,
),
ElevatedButton(
onPressed: _selectSourceAccount,
child: const Text('Pilih Akun Sumber dari Daftar'),
),
],
),
const SizedBox(height: 8),
// Destination account
const Text('Akun Tujuan:'),
_destinationAccountId != null
? Card(
child: ListTile(
title: Text(_destinationAccountName ?? ''),
trailing: const Icon(Icons.edit),
onTap: _selectDestinationAccount,
),
)
: Column(
children: [
TextField(
controller: _destinationAccountController,
decoration: const InputDecoration(
labelText: 'Nama Akun Tujuan',
hintText: 'Ketik nama akun atau pilih dari daftar',
),
onChanged: _onDestinationAccountChanged,
),
ElevatedButton(
onPressed: _selectDestinationAccount,
child: const Text('Pilih Akun Tujuan dari Daftar'),
),
],
),
if (_isLoadingAccounts)
const Padding(
padding: EdgeInsets.all(8.0),
child: Center(child: CircularProgressIndicator()),
),
],
),
),
),
// Total
Container(
padding: const EdgeInsets.all(16.0),
color: Colors.grey[200],
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Total:',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
Text(
'Rp ${CurrencyFormat.formatRupiahWithoutSymbol(_total)}',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
],
),
),
// TabBar untuk navigasi
Expanded(
child: DefaultTabController(
length: 2,
child: Column(
children: [
const TabBar(
tabs: [
Tab(text: 'Barang'),
Tab(text: 'Keranjang'),
],
),
Expanded(
child: TabBarView(
children: [
// Tab Barang
_buildItemsTab(),
// Tab Keranjang
_buildCartTab(),
],
),
),
],
),
),
),
],
),
bottomNavigationBar: Padding(
padding: const EdgeInsets.all(8.0),
child: ElevatedButton(
onPressed: _completeTransaction,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16.0),
backgroundColor: Colors.green,
),
child: const Text(
'BAYAR',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
),
);
}
Widget _buildItemsTab() {
return GridView.builder(
padding: const EdgeInsets.all(8.0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8.0,
mainAxisSpacing: 8.0,
childAspectRatio: 1.2,
),
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
final item = _filteredItems[index];
return Card(
child: InkWell(
onTap: () => _addToCart(item),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.name,
style: const TextStyle(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'Rp ${CurrencyFormat.formatRupiahWithoutSymbol(item.price)}',
style: const TextStyle(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'ID: ${item.id}',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
),
),
);
},
);
}
Widget _buildCartTab() {
if (_cart.isEmpty) {
return const Center(
child: Text('Keranjang masih kosong'),
);
}
return ListView.builder(
padding: const EdgeInsets.all(8.0),
itemCount: _cart.length,
itemBuilder: (context, index) {
final cartItem = _cart[index];
final item = cartItem['item'] as Item;
final quantity = cartItem['quantity'] as int;
final subtotal = item.price * quantity;
return Card(
child: ListTile(
title: Text(item.name),
subtitle: Text('Rp ${CurrencyFormat.formatRupiahWithoutSymbol(item.price)}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: () => _updateQuantity(index, quantity - 1),
icon: const Icon(Icons.remove),
),
Text('$quantity'),
IconButton(
onPressed: () => _updateQuantity(index, quantity + 1),
icon: const Icon(Icons.add),
),
const SizedBox(width: 8),
Text('Rp ${CurrencyFormat.formatRupiahWithoutSymbol(subtotal)}'),
IconButton(
onPressed: () => _removeFromCart(index),
icon: const Icon(Icons.delete, color: Colors.red),
),
],
),
),
);
},
);
}
}