diff --git a/lib/providers/receipt_provider.dart b/lib/providers/receipt_provider.dart new file mode 100644 index 0000000..09c53f8 --- /dev/null +++ b/lib/providers/receipt_provider.dart @@ -0,0 +1,172 @@ +// lib/providers/receipt_provider.dart + +import 'package:flutter/material.dart'; +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 + +class ReceiptProvider with ChangeNotifier { + late ReceiptState _state; + + ReceiptState get state => _state; + + ReceiptProvider() { + _state = ReceiptState(); + } + + /// Inisialisasi state awal + Future initialize() async { + await loadCredentialsAndAccounts(); + // Bisa menambahkan inisialisasi lain jika diperlukan + } + + /// 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) + _state = _state.copyWith( + fireflyUrl: null, + accessToken: null, + accounts: [], // Kosongkan akun juga + ); + notifyListeners(); + return; + } + + // Periksa apakah kredensial berubah + final credentialsChanged = _state.fireflyUrl != credentials['url'] || _state.accessToken != credentials['token']; + + _state = _state.copyWith( + fireflyUrl: credentials['url'], + accessToken: credentials['token'], + ); + notifyListeners(); + + // Jika kredensial ada dan berubah, lanjutkan untuk memuat akun + if (credentialsChanged) { + await loadAccounts(); + } else if (_state.accounts.isEmpty) { + // Jika akun belum pernah dimuat, muat sekarang + await loadAccounts(); + } + } + + /// Memuat daftar akun + Future loadAccounts() async { + if (_state.fireflyUrl == null || _state.accessToken == null) { + return; + } + + try { + final allAccounts = await ReceiptService.loadAccounts( + baseUrl: _state.fireflyUrl!, + accessToken: _state.accessToken!, + ); + + _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) + } + } + + /// Menambahkan item ke receipt + void addItem(ReceiptItem item) { + _state = _state.copyWith( + items: [..._state.items, item], + ); + notifyListeners(); + } + + /// 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, + ); + notifyListeners(); + } + + /// 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, + ); + notifyListeners(); + } + + /// Memilih akun sumber + void selectSourceAccount(String id, String name) { + _state = _state.copyWith( + sourceAccountId: id, + sourceAccountName: name, + ); + notifyListeners(); + } + + /// Memilih akun tujuan + void selectDestinationAccount(String id, String name) { + _state = _state.copyWith( + destinationAccountId: id, + destinationAccountName: name, + ); + notifyListeners(); + } + + /// Mencari ID akun berdasarkan nama dan tipe + String? findAccountIdByName(String name, String expectedType) { + return ReceiptService.findAccountIdByName( + name: name, + expectedType: expectedType, + accounts: _state.accounts, + ); + } + + /// Mengirim transaksi (ini akan memanggil ReceiptService dan mungkin memperbarui state setelahnya) + Future submitTransaction() async { + if (_state.items.isEmpty) { + throw Exception('Tidak ada item untuk dikirim'); + } + + if (_state.fireflyUrl == null || _state.accessToken == null) { + throw Exception('Kredensial Firefly III tidak ditemukan'); + } + + // 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'); + } + + final transactionId = await ReceiptService.submitTransaction( + items: _state.items, + transactionDate: _state.transactionDate, + sourceAccountId: _state.sourceAccountId!, + destinationAccountId: _state.destinationAccountId!, + accounts: _state.accounts, + baseUrl: _state.fireflyUrl!, + accessToken: _state.accessToken!, + ); + + return transactionId; + } + + // Tambahkan metode lain sesuai kebutuhan, seperti untuk memperbarui transactionDate jika diperlukan +} \ No newline at end of file diff --git a/lib/providers/receipt_state.dart b/lib/providers/receipt_state.dart new file mode 100644 index 0000000..c68867c --- /dev/null +++ b/lib/providers/receipt_state.dart @@ -0,0 +1,62 @@ +// lib/providers/receipt_state.dart + +import 'package:cashumit/models/receipt_item.dart'; + +class ReceiptState { + List items; + DateTime transactionDate; + List> accounts; + String? sourceAccountId; + String? sourceAccountName; + String? destinationAccountId; + String? destinationAccountName; + String? fireflyUrl; + String? accessToken; + + ReceiptState({ + List? items, + DateTime? transactionDate, + List>? accounts, + this.sourceAccountId, + this.sourceAccountName, + this.destinationAccountId, + this.destinationAccountName, + this.fireflyUrl, + this.accessToken, + }) : items = items ?? [], + transactionDate = transactionDate ?? DateTime.now(), + accounts = accounts ?? []; + + // CopyWith method untuk membuat salinan state dengan perubahan tertentu + ReceiptState copyWith({ + List? items, + DateTime? transactionDate, + List>? accounts, + String? sourceAccountId, + String? sourceAccountName, + String? destinationAccountId, + String? destinationAccountName, + String? fireflyUrl, + String? accessToken, + }) { + return ReceiptState( + items: items ?? this.items, + transactionDate: transactionDate ?? this.transactionDate, + accounts: accounts ?? this.accounts, + sourceAccountId: sourceAccountId ?? this.sourceAccountId, + sourceAccountName: sourceAccountName ?? this.sourceAccountName, + destinationAccountId: destinationAccountId ?? this.destinationAccountId, + destinationAccountName: destinationAccountName ?? this.destinationAccountName, + fireflyUrl: fireflyUrl ?? this.fireflyUrl, + accessToken: accessToken ?? this.accessToken, + ); + } + + // Method untuk menghitung total + double get total => items.fold(0.0, (sum, item) => sum + item.total); + + @override + String toString() { + return 'ReceiptState(items: $items, transactionDate: $transactionDate, accounts: $accounts, sourceAccountId: $sourceAccountId, sourceAccountName: $sourceAccountName, destinationAccountId: $destinationAccountId, destinationAccountName: $destinationAccountName, fireflyUrl: $fireflyUrl, accessToken: $accessToken)'; + } +} \ No newline at end of file diff --git a/lib/services/receipt_service.dart b/lib/services/receipt_service.dart new file mode 100644 index 0000000..5529381 --- /dev/null +++ b/lib/services/receipt_service.dart @@ -0,0 +1,215 @@ +// lib/services/receipt_service.dart + +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'; + +class ReceiptService { + /// Memuat kredensial dari shared preferences. + /// + /// Mengembalikan Map dengan key 'url' dan 'token' jika kredensial ada dan valid, + /// atau null jika tidak ditemukan atau tidak valid. + static Future?> loadCredentials() 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) { + return null; + } + + return {'url': url, 'token': token}; + } + + /// Memuat daftar akun sumber (revenue) dan tujuan (asset) dari API. + /// + /// Mengembalikan daftar akun yang berisi akun revenue dan asset. + /// Melempar exception jika terjadi kesalahan saat memuat akun. + static Future>> loadAccounts({ + required String baseUrl, + required String accessToken, + }) async { + // Mengambil akun revenue + final revenueAccounts = await FireflyApiService.fetchAccounts( + baseUrl: baseUrl, + accessToken: accessToken, + type: 'revenue', + ); + + // Mengambil akun asset + final assetAccounts = await FireflyApiService.fetchAccounts( + baseUrl: baseUrl, + 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, + }); + } + + return allAccounts; + } + + /// Mencari ID akun berdasarkan nama dan tipe akun. + static String? findAccountIdByName({ + required String name, + required String expectedType, + required List> accounts, + }) { + if (name.isEmpty) return null; + + try { + // Cari akun dengan nama yang cocok dan tipe yang diharapkan + final account = accounts.firstWhere( + (account) => + account['name'].toString().toLowerCase() == name.toLowerCase() && + account['type'] == expectedType, + ); + + return account['id'] as String?; + } catch (e) { + // Jika tidak ditemukan, coba pencarian yang lebih fleksibel + for (var account in accounts) { + if (account['type'] == expectedType && + account['name'] + .toString() + .toLowerCase() + .contains(name.toLowerCase())) { + return account['id'] as String?; + } + } + + // Jika masih tidak ditemukan, kembalikan null + return null; + } + } + + /// Generates a transaction description based on item names + 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(', '); + } + + /// Mengirim transaksi ke Firefly III. + /// + /// Sebelum mengirim transaksi, metode ini melakukan beberapa validasi: + /// 1. Memastikan ada item yang akan dikirim + /// 2. Memastikan akun sumber dan tujuan telah dipilih atau dimasukkan + /// 3. Memastikan akun sumber dan tujuan berbeda + /// 4. Memastikan akun sumber dan tujuan ada di daftar akun yang dimuat + /// 5. Memastikan tipe akun sesuai (sumber: revenue, tujuan: asset) + /// + /// Mengembalikan transactionId jika berhasil, atau null jika gagal. + /// Melempar exception jika ada error validasi. + static Future submitTransaction({ + required List items, + required DateTime transactionDate, + required String sourceAccountId, + required String destinationAccountId, + required List> accounts, // Daftar akun yang dimuat + required String baseUrl, + required String accessToken, + }) async { + if (items.isEmpty) { + throw Exception('Tidak ada item untuk dikirim'); + } + + // Validasi apakah akun benar-benar ada di Firefly III + bool sourceAccountExists = false; + bool destinationAccountExists = false; + + // Cari detail akun untuk validasi tipe akun + Map? sourceAccountDetails; + Map? destinationAccountDetails; + + for (var account in accounts) { + if (account['id'].toString() == sourceAccountId) { + sourceAccountExists = true; + sourceAccountDetails = account; + } + if (account['id'].toString() == destinationAccountId) { + destinationAccountExists = true; + destinationAccountDetails = account; + } + } + + if (!sourceAccountExists) { + throw Exception( + 'Akun sumber tidak ditemukan di daftar akun yang dimuat. Klik "Muat Ulang Akun" dan coba lagi.'); + } + + if (!destinationAccountExists) { + throw Exception( + 'Akun tujuan tidak ditemukan di daftar akun yang dimuat. Klik "Muat Ulang Akun" dan coba lagi.'); + } + + // Validasi tipe akun (sumber harus revenue, tujuan harus asset) + if (sourceAccountDetails != null && + sourceAccountDetails['type'] != 'revenue') { + // Tampilkan peringatan, tapi tidak menghentikan proses + print( + 'Peringatan: Akun sumber sebaiknya bertipe revenue, tetapi akun ini bertipe ${sourceAccountDetails['type']}.'); + } + + if (destinationAccountDetails != null && + destinationAccountDetails['type'] != 'asset') { + // Tampilkan peringatan, tapi tidak menghentikan proses + print( + 'Peringatan: Akun tujuan sebaiknya bertipe asset, tetapi akun ini bertipe ${destinationAccountDetails['type']}.'); + } + + if (sourceAccountId == destinationAccountId) { + throw Exception('Akun sumber dan tujuan tidak boleh sama'); + } + + final total = _calculateTotal(items); + + // Generate transaction description + final transactionDescription = generateTransactionDescription(items); + + final transactionId = await FireflyApiService.submitDummyTransaction( + baseUrl: baseUrl, + accessToken: accessToken, + sourceId: sourceAccountId, + destinationId: destinationAccountId, + type: 'deposit', + description: transactionDescription, + date: + '${transactionDate.year}-${transactionDate.month.toString().padLeft(2, '0')}-${transactionDate.day.toString().padLeft(2, '0')}', + amount: total.toStringAsFixed(2), + ); + + return transactionId; + } + + /// Menghitung total harga semua item + static double _calculateTotal(List items) { + return items.fold(0.0, (sum, item) => sum + item.total); + } +} \ No newline at end of file diff --git a/lib/widgets/receipt_body.dart b/lib/widgets/receipt_body.dart new file mode 100644 index 0000000..d4134d6 --- /dev/null +++ b/lib/widgets/receipt_body.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:cashumit/widgets/store_info_widget.dart'; +import 'package:cashumit/widgets/account_settings_widget.dart'; +import 'package:cashumit/widgets/receipt_item_list.dart'; +import 'package:cashumit/widgets/receipt_total.dart'; +import 'package:cashumit/widgets/store_disclaimer.dart'; +import 'package:cashumit/widgets/thank_you_pantun.dart'; +import 'package:cashumit/widgets/dotted_line.dart'; +import 'package:cashumit/widgets/horizontal_divider.dart'; +import 'package:cashumit/models/receipt_item.dart'; + +class ReceiptBody extends StatelessWidget { + final List items; + final String? sourceAccountName; + final String? destinationAccountName; + final VoidCallback onOpenStoreInfoConfig; + final VoidCallback onSelectSourceAccount; + final VoidCallback onSelectDestinationAccount; + final VoidCallback onOpenCustomTextConfig; + final double total; + final Function(int)? onEditItem; + final Function(int)? onRemoveItem; + final VoidCallback onAddItem; + + const ReceiptBody({ + Key? key, + required this.items, + this.sourceAccountName, + this.destinationAccountName, + required this.onOpenStoreInfoConfig, + required this.onSelectSourceAccount, + required this.onSelectDestinationAccount, + required this.onOpenCustomTextConfig, + required this.total, + this.onEditItem, + this.onRemoveItem, + required this.onAddItem, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + width: 360, + decoration: const BoxDecoration( + color: Colors.white, + ), + child: Column( + children: [ + // Informasi toko dengan widget yang dapat diedit + StoreInfoWidget( + onTap: onOpenStoreInfoConfig, + ), + // Garis pembatas + const DottedLine(), + const SizedBox(height: 8), + // Pengaturan akun sumber dan destinasi + AccountSettingsWidget( + sourceAccount: sourceAccountName ?? 'Pilih Sumber', + destinationAccount: destinationAccountName ?? 'Pilih Tujuan', + onSelectSource: onSelectSourceAccount, + onSelectDestination: onSelectDestinationAccount, + ), + const SizedBox(height: 8), + // Garis pemisah + const HorizontalDivider(), + // Daftar item + ReceiptItemList( + items: items, + onEditItem: onEditItem, + onRemoveItem: onRemoveItem, + onAddItem: onAddItem, + ), + // Garis pembatas + const HorizontalDivider(), + // Total harga keseluruhan + ReceiptTotal(total: total), + const SizedBox(height: 8), + // Garis pembatas + const DottedLine(), + // Disclaimer toko + StoreDisclaimer(onTap: onOpenCustomTextConfig), + // Ucapan terima kasih dan pantun + ThankYouPantun(onTap: onOpenCustomTextConfig), + const SizedBox(height: 16), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/receipt_item_list.dart b/lib/widgets/receipt_item_list.dart new file mode 100644 index 0000000..3340685 --- /dev/null +++ b/lib/widgets/receipt_item_list.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:cashumit/models/receipt_item.dart'; +import 'package:cashumit/widgets/receipt_item_widget.dart'; +import 'package:cashumit/widgets/add_item_button.dart'; +import 'package:cashumit/widgets/horizontal_divider.dart'; + +class ReceiptItemList extends StatefulWidget { + final List items; + final Function(int)? onEditItem; + final Function(int)? onRemoveItem; + final VoidCallback? onAddItem; + + const ReceiptItemList({ + Key? key, + required this.items, + this.onEditItem, + this.onRemoveItem, + this.onAddItem, + }) : super(key: key); + + @override + State createState() => _ReceiptItemListState(); +} + +class _ReceiptItemListState extends State { + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Baris tabel keterangan + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 4, + child: Text( + 'ITEM', + style: TextStyle( + fontFamily: 'Courier', + fontSize: 14, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.left, + ), + ), + Expanded( + flex: 1, + child: Text( + 'QTY', + style: TextStyle( + fontFamily: 'Courier', + fontSize: 14, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + Expanded( + flex: 2, + child: Text( + 'HARGA', + style: TextStyle( + fontFamily: 'Courier', + fontSize: 14, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.right, + ), + ), + Expanded( + flex: 2, + child: Text( + 'TOTAL', + style: TextStyle( + fontFamily: 'Courier', + fontSize: 14, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.right, + ), + ), + ], + ), + // Garis pembatas + const HorizontalDivider(), + // Daftar item dengan Dismissible untuk swipe-to-delete + ...widget.items.asMap().entries.map((entry) { + int index = entry.key; + ReceiptItem item = entry.value; + + return Dismissible( + key: Key('${item.hashCode}_$index'), + direction: DismissDirection.horizontal, + background: Container( + color: Colors.red, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 20.0), + child: const Icon(Icons.delete, color: Colors.white), + ), + secondaryBackground: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20.0), + child: const Icon(Icons.delete, color: Colors.white), + ), + confirmDismiss: (direction) async { + // Untuk saat ini, kita mengembalikan true untuk semua arah + // Nanti bisa diganti dengan dialog konfirmasi + return true; + }, + onDismissed: (direction) { + // Panggil callback onRemoveItem jika tersedia + widget.onRemoveItem?.call(index); + }, + child: GestureDetector( + onTap: () => widget.onEditItem?.call(index), + child: ReceiptItemWidget(item: item), + ), + ); + }).toList(), + // Tombol tambah item + AddItemButton(onTap: widget.onAddItem ?? () {}), + ], + ); + } +} \ No newline at end of file