From 28e99e4e2faf7c6282907db0286a48548b705e61 Mon Sep 17 00:00:00 2001 From: a2nr Date: Sun, 24 Aug 2025 11:16:40 +0700 Subject: [PATCH] feat: Implement ReceiptScreen with provider pattern and clean architecture Co-authored-by: Qwen-Coder Mengimplementasikan ReceiptScreen dengan pola provider untuk manajemen state yang lebih baik. Memisahkan logika bisnis ke service terpisah dan membuat widget komponen yang reusable untuk struk. Menambahkan fitur konfigurasi informasi toko dan teks kustom (disclaimer, thank you note, pantun). --- lib/providers/receipt_provider.dart | 172 ++++++++++++++++++++++ lib/providers/receipt_state.dart | 62 ++++++++ lib/services/receipt_service.dart | 215 ++++++++++++++++++++++++++++ lib/widgets/receipt_body.dart | 89 ++++++++++++ lib/widgets/receipt_item_list.dart | 126 ++++++++++++++++ 5 files changed, 664 insertions(+) create mode 100644 lib/providers/receipt_provider.dart create mode 100644 lib/providers/receipt_state.dart create mode 100644 lib/services/receipt_service.dart create mode 100644 lib/widgets/receipt_body.dart create mode 100644 lib/widgets/receipt_item_list.dart 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