diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5a8468b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,65 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "cashumit", + "request": "launch", + "type": "dart" + }, + { + "name": "cashumit (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "cashumit (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "plugins", + "cwd": "plugins", + "request": "launch", + "type": "dart" + }, + { + "name": "plugins (profile mode)", + "cwd": "plugins", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "plugins (release mode)", + "cwd": "plugins", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "example", + "cwd": "plugins/example", + "request": "launch", + "type": "dart" + }, + { + "name": "example (profile mode)", + "cwd": "plugins/example", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "example (release mode)", + "cwd": "plugins/example", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/key/upload-keystore.jks b/key/upload-keystore.jks new file mode 100644 index 0000000..af34629 Binary files /dev/null and b/key/upload-keystore.jks differ diff --git a/lib/features/transaction_screen.dart.bak b/lib/features/transaction_screen.dart.bak new file mode 100644 index 0000000..7557076 --- /dev/null +++ b/lib/features/transaction_screen.dart.bak @@ -0,0 +1,1064 @@ +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:cashumit/widgets/printing_status_card.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class TransactionScreen extends StatefulWidget { + final List items; + + const TransactionScreen({ + super.key, + required this.items, + }); + + @override + State createState() => _TransactionScreenState(); +} + +class _TransactionScreenState extends State { + final BluetoothPrint bluetoothPrint = BluetoothPrint.instance; + List _devices = []; + BluetoothDevice? _selectedDevice; + bool _isScanning = false; + final List> _cart = []; + int _total = 0; + String _paymentMethod = 'Tunai'; + final TextEditingController _searchController = TextEditingController(); + List _filteredItems = []; + + // Transaction settings directly in this screen + late DateTime _transactionDate; + List> _accounts = []; + String? _sourceAccountId; + String? _sourceAccountName; + String? _destinationAccountId; + String? _destinationAccountName; + bool _isLoadingAccounts = false; + + // Printing status + bool _isPrinting = 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 _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 _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 = >[]; + 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 _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 _selectSourceAccount() async { + if (_accounts.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Daftar akun belum dimuat')), + ); + } + return; + } + + final selectedAccount = await showDialog?>( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Pilih Akun Sumber'), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: _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 _selectDestinationAccount() async { + if (_accounts.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Daftar akun belum dimuat')), + ); + } + return; + } + + final selectedAccount = await showDialog?>( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Pilih Akun Tujuan'), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: _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 _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( + 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; + + // Tampilkan status printing + setState(() { + _isPrinting = true; + }); + + // Cetak struk + final printService = PrintService(); + final printed = await printService.printTransaction( + transaction, + 'TOKO SEMBAKO MURAH', + 'Jl. Merdeka No. 123, Jakarta', + ); + + // Sembunyikan status printing + setState(() { + _isPrinting = false; + }); + + 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 Stack( + children: [ + 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( + 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( + 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), + ), + ), + ), + ), + PrintingStatusCard( + isVisible: _isPrinting, + onDismiss: () { + setState(() { + _isPrinting = false; + }); + }, + ), + ], + ); + } + 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( + 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( + 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), + ), + ), + ), + PrintingStatusCard( + isVisible: _isPrinting, + onDismiss: () { + setState(() { + _isPrinting = false; + }); + }, + ), + ]); + } + } + + 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), + ), + ], + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 42f28fe..90f4fd5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,13 +2,14 @@ import 'package:cashumit/screens/config_screen.dart'; import 'package:cashumit/screens/transaction_screen.dart'; import 'package:flutter/material.dart'; import 'package:cashumit/screens/receipt_screen.dart'; +import 'package:cashumit/screens/local_receipts_screen.dart'; import 'package:provider/provider.dart'; import 'package:cashumit/providers/receipt_provider.dart'; void main() async { // Ensure WidgetsFlutterBinding is initialized for async operations WidgetsFlutterBinding.ensureInitialized(); - + runApp( MultiProvider( providers: [ @@ -35,7 +36,8 @@ class MyApp extends StatelessWidget { '/': (context) => const ReceiptScreen(), '/transaction': (context) => const TransactionScreen(), '/config': (context) => const ConfigScreen(), + '/local-receipts': (context) => const LocalReceiptsScreen(), }, ); } -} \ No newline at end of file +} diff --git a/lib/models/local_receipt.dart b/lib/models/local_receipt.dart new file mode 100644 index 0000000..c24197b --- /dev/null +++ b/lib/models/local_receipt.dart @@ -0,0 +1,104 @@ +import 'package:cashumit/models/receipt_item.dart'; + +class LocalReceipt { + final String id; + final List items; + final String? sourceAccountId; + final String? sourceAccountName; + final String? destinationAccountId; + final String? destinationAccountName; + final DateTime transactionDate; + final String? + transactionDescription; // Deskripsi transaksi untuk tampilan di daftar + final bool isSubmitted; + final String? submissionError; + final DateTime? submittedAt; + final DateTime createdAt; + + LocalReceipt({ + required this.id, + required this.items, + this.sourceAccountId, + this.sourceAccountName, + this.destinationAccountId, + this.destinationAccountName, + required this.transactionDate, + this.transactionDescription, + this.isSubmitted = false, + this.submissionError, + this.submittedAt, + required this.createdAt, + }); + + double get total => items.fold(0.0, (sum, item) => sum + item.total); + + Map toJson() { + return { + 'id': id, + 'items': items.map((item) => item.toJson()).toList(), + 'sourceAccountId': sourceAccountId, + 'sourceAccountName': sourceAccountName, + 'destinationAccountId': destinationAccountId, + 'destinationAccountName': destinationAccountName, + 'transactionDate': transactionDate.toIso8601String(), + 'transactionDescription': transactionDescription, + 'isSubmitted': isSubmitted, + 'submissionError': submissionError, + 'submittedAt': submittedAt?.toIso8601String(), + 'createdAt': createdAt.toIso8601String(), + }; + } + + factory LocalReceipt.fromJson(Map json) { + return LocalReceipt( + id: json['id'], + items: (json['items'] as List) + .map((item) => ReceiptItem.fromJson(item)) + .toList(), + sourceAccountId: json['sourceAccountId'], + sourceAccountName: json['sourceAccountName'], + destinationAccountId: json['destinationAccountId'], + destinationAccountName: json['destinationAccountName'], + transactionDate: DateTime.parse(json['transactionDate']), + transactionDescription: json['transactionDescription'], + isSubmitted: json['isSubmitted'] ?? false, + submissionError: json['submissionError'], + submittedAt: json['submittedAt'] != null + ? DateTime.parse(json['submittedAt']) + : null, + createdAt: DateTime.parse(json['createdAt']), + ); + } + + LocalReceipt copyWith({ + String? id, + List? items, + String? sourceAccountId, + String? sourceAccountName, + String? destinationAccountId, + String? destinationAccountName, + DateTime? transactionDate, + String? transactionDescription, + bool? isSubmitted, + String? submissionError, + DateTime? submittedAt, + DateTime? createdAt, + }) { + return LocalReceipt( + id: id ?? this.id, + items: items ?? this.items, + sourceAccountId: sourceAccountId ?? this.sourceAccountId, + sourceAccountName: sourceAccountName ?? this.sourceAccountName, + destinationAccountId: destinationAccountId ?? this.destinationAccountId, + destinationAccountName: + destinationAccountName ?? this.destinationAccountName, + transactionDate: transactionDate ?? this.transactionDate, + transactionDescription: + transactionDescription ?? this.transactionDescription, + isSubmitted: isSubmitted ?? this.isSubmitted, + submissionError: submissionError ?? this.submissionError, + submittedAt: submittedAt ?? this.submittedAt, + createdAt: createdAt ?? this.createdAt, + ); + } +} diff --git a/lib/models/receipt_item.dart b/lib/models/receipt_item.dart index 4270731..26edc2f 100644 --- a/lib/models/receipt_item.dart +++ b/lib/models/receipt_item.dart @@ -11,9 +11,24 @@ class ReceiptItem { double get total => quantity * price; + Map toJson() { + return { + 'description': description, + 'quantity': quantity, + 'price': price, + }; + } + + factory ReceiptItem.fromJson(Map json) { + return ReceiptItem( + description: json['description'], + quantity: json['quantity'].toDouble(), + price: json['price'].toDouble(), + ); + } + @override String toString() { return 'ReceiptItem(description: $description, quantity: $quantity, price: $price)'; } } - diff --git a/lib/providers/receipt_provider.dart b/lib/providers/receipt_provider.dart index 09c53f8..78c9723 100644 --- a/lib/providers/receipt_provider.dart +++ b/lib/providers/receipt_provider.dart @@ -5,6 +5,10 @@ import 'package:cashumit/providers/receipt_state.dart'; import 'package:cashumit/models/receipt_item.dart'; import 'package:cashumit/services/receipt_service.dart'; // Pastikan ReceiptService sudah dibuat import 'package:cashumit/models/firefly_account.dart'; // Untuk tipe akun +import 'package:cashumit/models/local_receipt.dart'; +import 'package:cashumit/services/local_receipt_service.dart'; +import 'package:cashumit/services/account_cache_service.dart'; +import 'dart:math'; class ReceiptProvider with ChangeNotifier { late ReceiptState _state; @@ -24,7 +28,7 @@ class ReceiptProvider with ChangeNotifier { /// Memuat kredensial dan akun Future loadCredentialsAndAccounts() async { final credentials = await ReceiptService.loadCredentials(); - + if (credentials == null) { // Jika tidak ada kredensial, kita tetap perlu memberitahu listener bahwa state berubah // (misalnya untuk menampilkan pesan error di UI) @@ -38,7 +42,8 @@ class ReceiptProvider with ChangeNotifier { } // Periksa apakah kredensial berubah - final credentialsChanged = _state.fireflyUrl != credentials['url'] || _state.accessToken != credentials['token']; + final credentialsChanged = _state.fireflyUrl != credentials['url'] || + _state.accessToken != credentials['token']; _state = _state.copyWith( fireflyUrl: credentials['url'], @@ -49,32 +54,47 @@ class ReceiptProvider with ChangeNotifier { // Jika kredensial ada dan berubah, lanjutkan untuk memuat akun if (credentialsChanged) { await loadAccounts(); + // Juga perbarui cache akun dari server + try { + await ReceiptService.updateAccountCache( + baseUrl: _state.fireflyUrl!, + accessToken: _state.accessToken!, + ); + } catch (e) { + print('Gagal memperbarui cache akun: $e'); + } } else if (_state.accounts.isEmpty) { // Jika akun belum pernah dimuat, muat sekarang await loadAccounts(); } } - /// Memuat daftar akun + /// Memuat daftar akun dengan prioritas: server > cache > fallback Future loadAccounts() async { if (_state.fireflyUrl == null || _state.accessToken == null) { + // Jika kredensial tidak ada, coba ambil dari cache + final cachedAccounts = await AccountCacheService.getCachedAccounts(); + if (cachedAccounts.isNotEmpty) { + _state = _state.copyWith(accounts: cachedAccounts); + notifyListeners(); + } return; } - try { - final allAccounts = await ReceiptService.loadAccounts( + // Gunakan cache service untuk mendapatkan akun dengan prioritas + final allAccounts = + await AccountCacheService.getAccountsWithFallback(() async { + final accounts = await ReceiptService.loadAccounts( baseUrl: _state.fireflyUrl!, accessToken: _state.accessToken!, ); + // Perbarui cache dengan data terbaru dari server + await ReceiptService.saveAccountsToCache(accounts); + return accounts; + }); - _state = _state.copyWith(accounts: allAccounts); - notifyListeners(); - } catch (error) { - // Error handling bisa dilakukan di sini atau dibiarkan untuk ditangani oleh UI - print('Error in ReceiptProvider.loadAccounts: $error'); - // Bisa memicu state error jika diperlukan - notifyListeners(); // Tetap notify untuk memperbarui UI (misalnya menampilkan pesan error) - } + _state = _state.copyWith(accounts: allAccounts); + notifyListeners(); } /// Menambahkan item ke receipt @@ -88,10 +108,10 @@ class ReceiptProvider with ChangeNotifier { /// Mengedit item di receipt void editItem(int index, ReceiptItem newItem) { if (index < 0 || index >= _state.items.length) return; - + final updatedItems = List.from(_state.items); updatedItems[index] = newItem; - + _state = _state.copyWith( items: updatedItems, ); @@ -101,9 +121,9 @@ class ReceiptProvider with ChangeNotifier { /// Menghapus item dari receipt void removeItem(int index) { if (index < 0 || index >= _state.items.length) return; - + final updatedItems = List.from(_state.items)..removeAt(index); - + _state = _state.copyWith( items: updatedItems, ); @@ -150,7 +170,7 @@ class ReceiptProvider with ChangeNotifier { // Pastikan akun sumber dan tujuan dipilih // Logika untuk mencari ID berdasarkan nama jika diperlukan bisa ditambahkan di sini // atau diharapkan UI sudah menangani ini sebelum memanggil submitTransaction - + if (_state.sourceAccountId == null || _state.destinationAccountId == null) { throw Exception('Akun sumber dan tujuan harus dipilih'); } @@ -167,6 +187,87 @@ class ReceiptProvider with ChangeNotifier { return transactionId; } - + + /// Simpan transaksi ke penyimpanan lokal (ini akan menyimpan transaksi lokal bukan langsung submit ke server) + Future saveTransactionLocally() async { + if (_state.items.isEmpty) { + throw Exception('Tidak ada item untuk disimpan'); + } + + if (_state.sourceAccountId == null || _state.destinationAccountId == null) { + throw Exception('Akun sumber dan tujuan harus dipilih'); + } + + // Buat ID unik untuk receipt lokal + final receiptId = + 'receipt_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(1000)}'; + + // Generate transaction description + final transactionDescription = + _generateTransactionDescription(_state.items); + + // Buat objek LocalReceipt + final localReceipt = LocalReceipt( + id: receiptId, + items: _state.items, + sourceAccountId: _state.sourceAccountId, + sourceAccountName: _state.sourceAccountName, + destinationAccountId: _state.destinationAccountId, + destinationAccountName: _state.destinationAccountName, + transactionDate: _state.transactionDate, + transactionDescription: transactionDescription, + createdAt: DateTime.now(), + ); + + // Simpan ke penyimpanan lokal + await LocalReceiptService.saveReceipt(localReceipt); + + // Kosongkan items di state setelah disimpan + _state = _state.copyWith( + items: [], + sourceAccountId: null, + sourceAccountName: null, + destinationAccountId: null, + destinationAccountName: null, + ); + notifyListeners(); + + return receiptId; + } + + /// Submit semua receipt lokal yang belum terkirim ke server + Future> submitAllLocalReceipts() async { + if (_state.fireflyUrl == null || _state.accessToken == null) { + throw Exception('Kredensial Firefly III tidak ditemukan'); + } + + // Submit semua receipt lokal yang belum terkirim + final result = await LocalReceiptService.submitAllUnsubmittedReceipts( + _state.fireflyUrl!, + _state.accessToken!, + ); + + notifyListeners(); // Update UI jika ada perubahan + return result; + } + + /// Generate transaction description from items + String _generateTransactionDescription(List items) { + 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(', '); + } + // Tambahkan metode lain sesuai kebutuhan, seperti untuk memperbarui transactionDate jika diperlukan -} \ No newline at end of file +} diff --git a/lib/screens/local_receipts_screen.dart b/lib/screens/local_receipts_screen.dart new file mode 100644 index 0000000..368371b --- /dev/null +++ b/lib/screens/local_receipts_screen.dart @@ -0,0 +1,402 @@ +import 'package:flutter/material.dart'; +import 'package:cashumit/models/local_receipt.dart'; +import 'package:cashumit/services/local_receipt_service.dart'; +import 'package:cashumit/services/receipt_service.dart'; +import 'package:intl/intl.dart'; + +class LocalReceiptsScreen extends StatefulWidget { + const LocalReceiptsScreen({super.key}); + + @override + State createState() => _LocalReceiptsScreenState(); +} + +class _LocalReceiptsScreenState extends State { + List receipts = []; + bool isLoading = true; + bool isSubmitting = false; + + @override + void initState() { + super.initState(); + _loadReceipts(); + } + + Future _loadReceipts() async { + setState(() { + isLoading = true; + }); + try { + final loadedReceipts = await LocalReceiptService.getReceipts(); + setState(() { + receipts = loadedReceipts; + isLoading = false; + }); + } catch (e) { + setState(() { + isLoading = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal memuat daftar nota: $e')), + ); + } + } + } + + Future _submitAllReceipts() async { + final credentials = await ReceiptService.loadCredentials(); + if (credentials == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Silakan konfigurasi kredensial FireFly III terlebih dahulu'), + ), + ); + } + return; + } + + setState(() { + isSubmitting = true; + }); + + try { + final result = await LocalReceiptService.submitAllUnsubmittedReceipts( + credentials['url']!, + credentials['token']!, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Berhasil: ${result['successCount']} berhasil, ${result['failureCount']} gagal dari ${result['totalCount']} total nota', + ), + ), + ); + } + + await _loadReceipts(); // Refresh daftar setelah submit + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal mengirim nota: $e')), + ); + } + } finally { + setState(() { + isSubmitting = false; + }); + } + } + + Future _deleteReceipt(String receiptId) async { + try { + await LocalReceiptService.removeReceipt(receiptId); + await _loadReceipts(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Nota berhasil dihapus')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Gagal menghapus nota: $e')), + ); + } + } + } + + String _formatCurrency(double amount) { + final formatter = NumberFormat.currency( + locale: 'id_ID', + symbol: 'Rp ', + decimalDigits: 0, + ); + return formatter.format(amount).replaceAll('.00', ''); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Nota Tersimpan'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: isLoading ? null : _loadReceipts, + ), + ], + ), + body: Column( + children: [ + // Summary card + Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Column( + children: [ + Text( + receipts + .where((r) => !r.isSubmitted) + .length + .toString(), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.orange, + ), + ), + const Text( + 'Menunggu', + style: TextStyle(color: Colors.grey), + ), + ], + ), + Column( + children: [ + Text( + receipts + .where((r) => r.isSubmitted) + .length + .toString(), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ), + const Text( + 'Terkirim', + style: TextStyle(color: Colors.grey), + ), + ], + ), + Column( + children: [ + Text( + receipts.length.toString(), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + const Text( + 'Total', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: isSubmitting ? null : _submitAllReceipts, + icon: isSubmitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Icon(Icons.sync), + label: + Text(isSubmitting ? 'Mengirim...' : 'Kirim Semua Nota'), + ), + ], + ), + ), + // Receipts list + Expanded( + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : receipts.isEmpty + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.receipt_outlined, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text( + 'Belum ada nota tersimpan', + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ], + ), + ) + : RefreshIndicator( + onRefresh: _loadReceipts, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: receipts.length, + itemBuilder: (context, index) { + final receipt = receipts[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + contentPadding: const EdgeInsets.all(16), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: receipt.isSubmitted + ? Colors.green.shade100 + : Colors.orange.shade100, + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + receipt.isSubmitted + ? Icons.check_circle + : Icons.access_time, + color: receipt.isSubmitted + ? Colors.green + : Colors.orange, + size: 20, + ), + ), + title: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + // Transaction description di kiri + Expanded( + flex: 2, + child: Text( + receipt.transactionDescription ?? + 'Transaksi Struk Belanja', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + // Total di kanan + Expanded( + child: Text( + _formatCurrency(receipt.total), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Colors.blue, + ), + textAlign: TextAlign.right, + ), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + 'Dibuat: ${DateFormat('dd/MM/yyyy HH:mm').format(receipt.createdAt)}', + style: const TextStyle(fontSize: 12), + ), + if (receipt.isSubmitted) + Text( + 'Dikirim: ${DateFormat('dd/MM/yyyy HH:mm').format(receipt.submittedAt ?? receipt.createdAt)}', + style: const TextStyle( + fontSize: 12, + color: Colors.green, + ), + ), + if (receipt.submissionError != null) + Text( + 'Error: ${receipt.submissionError}', + style: const TextStyle( + fontSize: 12, + color: Colors.red, + ), + ), + ], + ), + trailing: PopupMenuButton( + onSelected: (String action) { + if (action == 'delete') { + _showDeleteConfirmation(receipt.id); + } + }, + itemBuilder: (BuildContext context) { + return [ + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete, size: 18), + SizedBox(width: 8), + Text('Hapus'), + ], + ), + ), + ]; + }, + ), + ), + ); + }, + ), + ), + ), + ], + ), + ); + } + + Future _showDeleteConfirmation(String receiptId) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Konfirmasi Hapus'), + content: const Text('Apakah Anda yakin ingin menghapus nota ini?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Batal'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Hapus'), + ), + ], + ), + ); + + if (result == true) { + _deleteReceipt(receiptId); + } + } +} diff --git a/lib/screens/receipt_screen.dart b/lib/screens/receipt_screen.dart index 2eeb742..ee2ec81 100644 --- a/lib/screens/receipt_screen.dart +++ b/lib/screens/receipt_screen.dart @@ -4,6 +4,7 @@ import 'package:cashumit/screens/add_item_screen.dart'; import 'package:bluetooth_print/bluetooth_print.dart'; import 'package:cashumit/services/bluetooth_service.dart'; import 'package:cashumit/widgets/receipt_body.dart'; +import 'package:cashumit/screens/local_receipts_screen.dart'; // Import widget komponen struk baru import 'package:cashumit/widgets/receipt_tear_effect.dart'; @@ -35,7 +36,7 @@ class ReceiptScreen extends StatefulWidget { class _ReceiptScreenState extends State { // Bluetooth service final BluetoothService _bluetoothService = BluetoothService(); - + // Printing status bool _isPrinting = false; @@ -46,7 +47,7 @@ class _ReceiptScreenState extends State { _initBluetooth(); _loadSavedBluetoothDevice(); print('Selesai inisialisasi ReceiptScreen'); - + // Panggil inisialisasi provider setelah widget dibuat WidgetsBinding.instance.addPostFrameCallback((_) { final receiptProvider = context.read(); @@ -114,7 +115,7 @@ class _ReceiptScreenState extends State { Future _printToThermalPrinter() async { final receiptProvider = context.read(); final state = receiptProvider.state; - + try { // Cek dan reconnect jika perlu final isConnected = await _bluetoothService.reconnectIfNeeded(); @@ -122,25 +123,24 @@ class _ReceiptScreenState extends State { if (!mounted) return; if (_bluetoothService.connectedDevice != null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Gagal menyambungkan ke printer')), + const SnackBar(content: Text('Gagal menyambungkan ke printer')), ); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Harap hubungkan printer terlebih dahulu')), + content: Text('Harap hubungkan printer terlebih dahulu')), ); } return; } - + await EscPosPrintService.printToThermalPrinter( items: state.items, transactionDate: state.transactionDate, context: context, bluetoothService: _bluetoothService, ); - + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Perintah cetak dikirim ke printer')), @@ -165,14 +165,14 @@ class _ReceiptScreenState extends State { ); } } - + /// Methods to control printing status void _startPrinting() { setState(() { _isPrinting = true; }); } - + void _endPrinting() { setState(() { _isPrinting = false; @@ -195,13 +195,13 @@ class _ReceiptScreenState extends State { void _editItem(int index) async { final receiptProvider = context.read(); final state = receiptProvider.state; - + // Pastikan index valid sebelum mengakses item if (index < 0 || index >= state.items.length) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text("Gagal mengedit item: Index tidak valid.")), + content: Text("Gagal mengedit item: Index tidak valid.")), ); } return; @@ -228,18 +228,14 @@ class _ReceiptScreenState extends State { Future _selectSourceAccount() async { final receiptProvider = context.read(); final state = receiptProvider.state; - + final selectedAccount = await AccountDialogService.showSourceAccountDialog( - context, - state.accounts - ); + context, state.accounts); if (selectedAccount != null) { // Memperbarui state melalui provider receiptProvider.selectSourceAccount( - selectedAccount['id'].toString(), - selectedAccount['name'].toString() - ); + selectedAccount['id'].toString(), selectedAccount['name'].toString()); } } @@ -247,18 +243,15 @@ class _ReceiptScreenState extends State { Future _selectDestinationAccount() async { final receiptProvider = context.read(); final state = receiptProvider.state; - - final selectedAccount = await AccountDialogService.showDestinationAccountDialog( - context, - state.accounts - ); + + final selectedAccount = + await AccountDialogService.showDestinationAccountDialog( + context, state.accounts); if (selectedAccount != null) { // Memperbarui state melalui provider receiptProvider.selectDestinationAccount( - selectedAccount['id'].toString(), - selectedAccount['name'].toString() - ); + selectedAccount['id'].toString(), selectedAccount['name'].toString()); } } @@ -266,20 +259,21 @@ class _ReceiptScreenState extends State { Future _sendToFirefly() async { final receiptProvider = context.read(); final state = receiptProvider.state; - + try { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Mengirim transaksi ke Firefly III...')), ); - + final transactionId = await receiptProvider.submitTransaction(); - + if (!mounted) return; if (transactionId != null && transactionId != "success") { // Navigasi ke WebViewScreen untuk menampilkan transaksi if (state.fireflyUrl != null) { - final transactionUrl = '${state.fireflyUrl}/transactions/show/$transactionId'; + final transactionUrl = + '${state.fireflyUrl}/transactions/show/$transactionId'; if (mounted) { Navigator.push( context, @@ -296,15 +290,15 @@ class _ReceiptScreenState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: - Text('Transaksi berhasil dikirim ke Firefly III (tanpa ID)')), + 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.')), + content: Text( + 'Gagal mengirim transaksi ke Firefly III. Periksa log untuk detail kesalahan.')), ); } } catch (e) { @@ -355,6 +349,12 @@ class _ReceiptScreenState extends State { } } + /// Membuka layar daftar receipt lokal. + void _openLocalReceipts() async { + // Gunakan route yang sudah didefinisikan di main.dart + await Navigator.pushNamed(context, '/local-receipts'); + } + final TextStyle baseTextStyle = const TextStyle( fontFamily: 'Courier', // Gunakan font courier jika tersedia fontSize: 14, @@ -369,23 +369,23 @@ class _ReceiptScreenState extends State { backgroundColor: Colors.grey[300], // Latar belakang abu-abu untuk efek struk floatingActionButton: Consumer( - builder: (context, receiptProvider, child) { - final state = receiptProvider.state; - return ReceiptSpeedDial( - bluetoothService: _bluetoothService, - onCheckConnection: _checkBluetoothConnection, - onPrint: _printToThermalPrinter, - onSettings: _openSettings, - onReloadAccounts: receiptProvider.loadAccounts, - hasItems: state.items.isNotEmpty, - hasSourceAccount: state.sourceAccountId != null, - hasDestinationAccount: state.destinationAccountId != null, - onSendToFirefly: _sendToFirefly, - onPrintingStart: _startPrinting, - onPrintingEnd: _endPrinting, - ); - } - ), + builder: (context, receiptProvider, child) { + final state = receiptProvider.state; + return ReceiptSpeedDial( + bluetoothService: _bluetoothService, + onCheckConnection: _checkBluetoothConnection, + onPrint: _printToThermalPrinter, + onSettings: _openSettings, + onReloadAccounts: receiptProvider.loadAccounts, + hasItems: state.items.isNotEmpty, + hasSourceAccount: state.sourceAccountId != null, + hasDestinationAccount: state.destinationAccountId != null, + onSendToFirefly: _sendToFirefly, + onPrintingStart: _startPrinting, + onPrintingEnd: _endPrinting, + onOpenLocalReceipts: _openLocalReceipts, + ); + }), body: SafeArea( child: Center( // Membungkus dengan widget Center untuk memastikan struk berada di tengah @@ -397,7 +397,8 @@ class _ReceiptScreenState extends State { // Background untuk efek kertas struk tersobek di bagian atas Container( width: 360, - color: const Color(0xFFE0E0E0), // Warna latar belakang sesuai dengan latar belakang layar + color: const Color( + 0xFFE0E0E0), // Warna latar belakang sesuai dengan latar belakang layar child: const Column( children: [ SizedBox(height: 15), // Jarak atas yang lebih besar @@ -408,42 +409,46 @@ class _ReceiptScreenState extends State { // Konten struk Consumer( - builder: (context, receiptProvider, child) { - final state = receiptProvider.state; - return ReceiptBody( - items: state.items, - sourceAccountName: state.sourceAccountName, - destinationAccountName: state.destinationAccountName, - onOpenStoreInfoConfig: _openStoreInfoConfig, - onSelectSourceAccount: () => _selectSourceAccount(), // Memanggil fungsi langsung - onSelectDestinationAccount: () => _selectDestinationAccount(), // Memanggil fungsi langsung - onOpenCustomTextConfig: _openCustomTextConfig, - total: state.total, - onEditItem: (index) => _editItem(index), // Memanggil fungsi langsung - onRemoveItem: (index) { - // Validasi index untuk mencegah error - if (index >= 0 && index < state.items.length) { - // Hapus item dari daftar melalui provider - receiptProvider.removeItem(index); - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + builder: (context, receiptProvider, child) { + final state = receiptProvider.state; + return ReceiptBody( + items: state.items, + sourceAccountName: state.sourceAccountName, + destinationAccountName: state.destinationAccountName, + onOpenStoreInfoConfig: _openStoreInfoConfig, + onSelectSourceAccount: () => + _selectSourceAccount(), // Memanggil fungsi langsung + onSelectDestinationAccount: () => + _selectDestinationAccount(), // Memanggil fungsi langsung + onOpenCustomTextConfig: _openCustomTextConfig, + total: state.total, + onEditItem: (index) => + _editItem(index), // Memanggil fungsi langsung + onRemoveItem: (index) { + // Validasi index untuk mencegah error + if (index >= 0 && index < state.items.length) { + // Hapus item dari daftar melalui provider + receiptProvider.removeItem(index); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( content: Text( "Gagal menghapus item: Index tidak valid.")), - ); - } + ); } - }, - onAddItem: () => _addItem(), // Memanggil fungsi langsung - ); - } - ), + } + }, + onAddItem: () => + _addItem(), // Memanggil fungsi langsung + ); + }), // Background untuk efek kertas struk tersobek di bagian bawah Container( width: 360, - color: const Color(0xFFE0E0E0), // Warna latar belakang sesuai dengan latar belakang layar + color: const Color( + 0xFFE0E0E0), // Warna latar belakang sesuai dengan latar belakang layar child: const Column( children: [ ReceiptTearBottom(), // Efek kertas struk tersobek di bagian bawah @@ -468,4 +473,4 @@ class _ReceiptScreenState extends State { ], ); } -} \ No newline at end of file +} diff --git a/lib/services/account_cache_service.dart b/lib/services/account_cache_service.dart new file mode 100644 index 0000000..1feba9e --- /dev/null +++ b/lib/services/account_cache_service.dart @@ -0,0 +1,85 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; + +class AccountCacheService { + static const String _accountsKey = 'cached_accounts'; + static const String _lastUpdatedKey = 'accounts_last_updated'; + + /// Simpan akun ke cache lokal + static Future saveAccountsLocally( + List> accounts) async { + final prefs = await SharedPreferences.getInstance(); + + // Konversi akun ke JSON string + final accountsJson = + accounts.map((account) => json.encode(account)).toList(); + + await prefs.setStringList(_accountsKey, accountsJson); + await prefs.setInt(_lastUpdatedKey, DateTime.now().millisecondsSinceEpoch); + } + + /// Ambil akun dari cache lokal + static Future>> getCachedAccounts() async { + final prefs = await SharedPreferences.getInstance(); + final accountsJsonList = prefs.getStringList(_accountsKey) ?? []; + + if (accountsJsonList.isEmpty) { + return []; + } + + return accountsJsonList + .map((jsonString) => json.decode(jsonString) as Map) + .toList(); + } + + /// Periksa apakah cache akun masih valid (kurang dari 1 jam) + static Future isCacheValid() async { + final prefs = await SharedPreferences.getInstance(); + final lastUpdated = prefs.getInt(_lastUpdatedKey) ?? 0; + final now = DateTime.now().millisecondsSinceEpoch; + + // Cache valid selama 1 jam (360000 ms) + return (now - lastUpdated) < 360000; + } + + /// Hapus cache akun + static Future clearCache() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_accountsKey); + await prefs.remove(_lastUpdatedKey); + } + + /// Update cache akun dari server + static Future updateAccountsFromServer( + List> accounts) async { + await saveAccountsLocally(accounts); + } + + /// Dapatkan akun dengan prioritas: cache valid > data server > fallback kosong + static Future>> getAccountsWithFallback( + Future>> Function() serverFetchFunction, + ) async { + // Coba ambil dari cache dulu + if (await isCacheValid()) { + final cachedAccounts = await getCachedAccounts(); + if (cachedAccounts.isNotEmpty) { + return cachedAccounts; + } + } + + // Jika cache tidak valid atau kosong, coba ambil dari server + try { + final serverAccounts = await serverFetchFunction(); + if (serverAccounts.isNotEmpty) { + // Simpan ke cache jika berhasil ambil dari server + await updateAccountsFromServer(serverAccounts); + return serverAccounts; + } + } catch (e) { + print('Gagal mengambil akun dari server: $e'); + } + + // Jika semua gagal, kembalikan cache terakhir (meskipun mungkin expired) + return await getCachedAccounts(); + } +} diff --git a/lib/services/local_receipt_service.dart b/lib/services/local_receipt_service.dart new file mode 100644 index 0000000..581e98e --- /dev/null +++ b/lib/services/local_receipt_service.dart @@ -0,0 +1,227 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; +import 'package:cashumit/models/local_receipt.dart'; +import 'package:cashumit/models/receipt_item.dart'; +import 'package:cashumit/services/firefly_api_service.dart'; + +class LocalReceiptService { + static const String _receiptsKey = 'local_receipts'; + + /// Save a receipt to local storage + static Future saveReceipt(LocalReceipt receipt) async { + final prefs = await SharedPreferences.getInstance(); + final receipts = await getReceipts(); + + // Check if receipt with the same ID already exists + final existingIndex = receipts.indexWhere((r) => r.id == receipt.id); + + if (existingIndex != -1) { + // Replace existing receipt + receipts[existingIndex] = receipt; + } else { + // Add new receipt + receipts.add(receipt); + } + + // Convert receipts to JSON strings + final receiptJsonList = + receipts.map((receipt) => json.encode(receipt.toJson())).toList(); + + await prefs.setStringList(_receiptsKey, receiptJsonList); + } + + /// Get all receipts from local storage + static Future> getReceipts() async { + final prefs = await SharedPreferences.getInstance(); + final receiptJsonList = prefs.getStringList(_receiptsKey) ?? []; + + return receiptJsonList + .map((jsonString) => LocalReceipt.fromJson(json.decode(jsonString))) + .toList(); + } + + /// Get unsubmitted receipts from local storage + static Future> getUnsubmittedReceipts() async { + final allReceipts = await getReceipts(); + return allReceipts.where((receipt) => !receipt.isSubmitted).toList(); + } + + /// Get submitted receipts from local storage + static Future> getSubmittedReceipts() async { + final allReceipts = await getReceipts(); + return allReceipts.where((receipt) => receipt.isSubmitted).toList(); + } + + /// Update a receipt's submission status + static Future updateReceiptSubmissionStatus(String receiptId, + {bool? isSubmitted, String? submissionError}) async { + final prefs = await SharedPreferences.getInstance(); + final receipts = await getReceipts(); + + final receiptIndex = receipts.indexWhere((r) => r.id == receiptId); + + if (receiptIndex != -1) { + final receipt = receipts[receiptIndex]; + receipts[receiptIndex] = receipt.copyWith( + isSubmitted: isSubmitted ?? receipt.isSubmitted, + submissionError: submissionError ?? receipt.submissionError, + submittedAt: isSubmitted == true ? DateTime.now() : receipt.submittedAt, + ); + + // Convert receipts to JSON strings + final receiptJsonList = + receipts.map((receipt) => json.encode(receipt.toJson())).toList(); + + await prefs.setStringList(_receiptsKey, receiptJsonList); + } + } + + /// Remove a receipt from local storage + static Future removeReceipt(String receiptId) async { + final prefs = await SharedPreferences.getInstance(); + final receipts = await getReceipts(); + + final filteredReceipts = + receipts.where((receipt) => receipt.id != receiptId).toList(); + + // Convert receipts to JSON strings + final receiptJsonList = filteredReceipts + .map((receipt) => json.encode(receipt.toJson())) + .toList(); + + await prefs.setStringList(_receiptsKey, receiptJsonList); + } + + /// Clear all receipts from local storage + static Future clearAllReceipts() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_receiptsKey); + } + + /// Submit a single receipt to FireFly III + static Future submitReceiptToServer( + LocalReceipt receipt, + String baseUrl, + String accessToken, + ) async { + try { + // Check if the receipt is already submitted + if (receipt.isSubmitted) { + return true; // Already submitted + } + + // Validate required fields + if (receipt.sourceAccountId == null || + receipt.destinationAccountId == null) { + await updateReceiptSubmissionStatus(receipt.id, + isSubmitted: false, submissionError: 'Missing account information'); + return false; + } + + // Submit to FireFly III + final transactionId = await _submitToFireFly( + receipt: receipt, + baseUrl: baseUrl, + accessToken: accessToken, + ); + + if (transactionId != null) { + // Update receipt status to submitted + await updateReceiptSubmissionStatus(receipt.id, + isSubmitted: true, submissionError: null); + return true; + } else { + await updateReceiptSubmissionStatus(receipt.id, + isSubmitted: false, submissionError: 'Failed to submit to server'); + return false; + } + } catch (e) { + await updateReceiptSubmissionStatus(receipt.id, + isSubmitted: false, submissionError: e.toString()); + return false; + } + } + + /// Submit all unsubmitted receipts to FireFly III + static Future> submitAllUnsubmittedReceipts( + String baseUrl, + String accessToken, + ) async { + final unsubmittedReceipts = await getUnsubmittedReceipts(); + final results = {}; + int successCount = 0; + int failureCount = 0; + + for (final receipt in unsubmittedReceipts) { + try { + final success = + await submitReceiptToServer(receipt, baseUrl, accessToken); + results[receipt.id] = success; + + if (success) { + successCount++; + } else { + failureCount++; + } + } catch (e) { + results[receipt.id] = false; + failureCount++; + // Update the receipt with the error + await updateReceiptSubmissionStatus(receipt.id, + isSubmitted: false, submissionError: e.toString()); + } + } + + return { + 'results': results, + 'successCount': successCount, + 'failureCount': failureCount, + 'totalCount': unsubmittedReceipts.length, + }; + } + + /// Private method to handle actual submission to FireFly III + static Future _submitToFireFly({ + required LocalReceipt receipt, + required String baseUrl, + required String accessToken, + }) async { + try { + final transactionId = await FireflyApiService.submitDummyTransaction( + baseUrl: baseUrl, + accessToken: accessToken, + sourceId: receipt.sourceAccountId!, + destinationId: receipt.destinationAccountId!, + type: 'deposit', // Assuming deposit for receipts + description: receipt.transactionDescription ?? + _generateTransactionDescription(receipt.items), + date: + '${receipt.transactionDate.year}-${receipt.transactionDate.month.toString().padLeft(2, '0')}-${receipt.transactionDate.day.toString().padLeft(2, '0')}', + amount: receipt.total.toStringAsFixed(2), + ); + + return transactionId; + } catch (e) { + print('Error submitting receipt to FireFly III: $e'); + return null; + } + } + + /// Generate transaction description + static String _generateTransactionDescription(List items) { + 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(', '); + } +} diff --git a/lib/services/receipt_service.dart b/lib/services/receipt_service.dart index 5529381..e68acda 100644 --- a/lib/services/receipt_service.dart +++ b/lib/services/receipt_service.dart @@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:cashumit/models/firefly_account.dart'; import 'package:cashumit/models/receipt_item.dart'; import 'package:cashumit/services/firefly_api_service.dart'; +import 'package:cashumit/services/account_cache_service.dart'; class ReceiptService { /// Memuat kredensial dari shared preferences. @@ -212,4 +213,29 @@ class ReceiptService { static double _calculateTotal(List items) { return items.fold(0.0, (sum, item) => sum + item.total); } -} \ No newline at end of file + + /// Fungsi untuk menyimpan akun ke cache + static Future saveAccountsToCache( + List> accounts) async { + await AccountCacheService.saveAccountsLocally(accounts); + } + + /// Fungsi untuk mengambil akun dari cache + static Future>> getCachedAccounts() async { + return await AccountCacheService.getCachedAccounts(); + } + + /// Fungsi untuk memperbarui cache akun dari server + static Future updateAccountCache({ + required String baseUrl, + required String accessToken, + }) async { + try { + final accounts = + await loadAccounts(baseUrl: baseUrl, accessToken: accessToken); + await AccountCacheService.updateAccountsFromServer(accounts); + } catch (e) { + print('Gagal memperbarui cache akun: $e'); + } + } +} diff --git a/lib/widgets/receipt_speed_dial.dart b/lib/widgets/receipt_speed_dial.dart index f15b6ed..fad4374 100644 --- a/lib/widgets/receipt_speed_dial.dart +++ b/lib/widgets/receipt_speed_dial.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_speed_dial/flutter_speed_dial.dart'; import 'package:cashumit/services/bluetooth_service.dart'; +import 'package:cashumit/screens/local_receipts_screen.dart'; +import 'package:provider/provider.dart'; +import 'package:cashumit/providers/receipt_provider.dart'; class ReceiptSpeedDial extends StatelessWidget { final BluetoothService bluetoothService; @@ -14,6 +17,7 @@ class ReceiptSpeedDial extends StatelessWidget { final Future Function() onSendToFirefly; final VoidCallback onPrintingStart; final VoidCallback onPrintingEnd; + final VoidCallback onOpenLocalReceipts; const ReceiptSpeedDial({ super.key, @@ -28,6 +32,7 @@ class ReceiptSpeedDial extends StatelessWidget { required this.onSendToFirefly, required this.onPrintingStart, required this.onPrintingEnd, + required this.onOpenLocalReceipts, }); @override @@ -38,6 +43,12 @@ class ReceiptSpeedDial extends StatelessWidget { spacing: 3, spaceBetweenChildren: 4, children: [ + SpeedDialChild( + child: const Icon(Icons.list_alt), + label: 'Lihat Nota Tersimpan', + onTap: onOpenLocalReceipts, + backgroundColor: Colors.blue, + ), SpeedDialChild( child: const Icon(Icons.send), label: 'Kirim ke Firefly', @@ -56,6 +67,48 @@ class ReceiptSpeedDial extends StatelessWidget { ? Colors.blue : Colors.grey, ), + SpeedDialChild( + child: const Icon(Icons.save), + label: 'Simpan untuk Dikirim Nanti', + onTap: hasItems && hasSourceAccount && hasDestinationAccount + ? () async { + final receiptProvider = context.read(); + try { + final receiptId = + await receiptProvider.saveTransactionLocally(); + if (receiptId != null) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Nota berhasil disimpan untuk dikirim nanti'), + ), + ); + } + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Gagal menyimpan nota: $e'), + ), + ); + } + } + } + : () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Pilih akun sumber dan tujuan terlebih dahulu'), + duration: Duration(seconds: 2), + ), + ); + }, + backgroundColor: hasItems && hasSourceAccount && hasDestinationAccount + ? Colors.orange + : Colors.grey, + ), SpeedDialChild( child: const Icon(Icons.refresh), label: 'Muat Ulang Akun', @@ -146,4 +199,3 @@ class ReceiptSpeedDial extends StatelessWidget { ); } } -