// 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/models/receipt_group.dart'; import 'package:cashumit/models/local_receipt.dart'; import 'package:cashumit/models/group_submission_result.dart'; import 'package:cashumit/services/receipt_service.dart'; import 'package:cashumit/services/local_receipt_service.dart'; import 'package:cashumit/services/account_cache_service.dart'; import 'package:cashumit/services/tag_cache_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'dart:math'; class ReceiptProvider with ChangeNotifier { late ReceiptState _state; ReceiptState get state => _state; ReceiptProvider() { _state = ReceiptState(); // Tambah 1 grup kosong default saat tampilan pertama _state = _state.copyWith(groups: [_newEmptyGroup()]); } /// Helper buat grup kosong baru dengan id unik ReceiptGroup _newEmptyGroup() { return ReceiptGroup( id: 'group_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(1000)}', ); } /// Initialize state and load credentials and accounts Future initialize() async { await loadCredentialsAndAccounts(); } /// Memuat kredensial dan akun Future loadCredentialsAndAccounts() async { final credentials = await ReceiptService.loadCredentials(); if (credentials == null) { _state = _state.copyWith( fireflyUrl: null, accessToken: null, accounts: [], ); notifyListeners(); return; } final credentialsChanged = _state.fireflyUrl != credentials['url'] || _state.accessToken != credentials['token']; _state = _state.copyWith( fireflyUrl: credentials['url'], accessToken: credentials['token'], ); // Muat akun tujuan permanen dari prefs await reloadDestinationFromPrefs(); notifyListeners(); if (credentialsChanged) { await loadAccounts(); await loadTags(); try { await ReceiptService.updateAccountMirror( baseUrl: _state.fireflyUrl!, accessToken: _state.accessToken!, ); } catch (e) { print('Gagal memperbarui mirror akun: $e'); } } else { if (_state.accounts.isEmpty) { await loadAccounts(); } if (_state.availableTags.isEmpty) { await loadTags(); } } } /// Memuat daftar tag dari server atau cache Future loadTags() async { if (_state.fireflyUrl == null || _state.accessToken == null) { final cachedTags = await TagCacheService.getCachedTags(); if (cachedTags.isNotEmpty) { _state = _state.copyWith(availableTags: cachedTags); notifyListeners(); } return; } try { final tags = await ReceiptService.loadTagsWithFallback( baseUrl: _state.fireflyUrl!, accessToken: _state.accessToken!, ); _state = _state.copyWith(availableTags: tags); notifyListeners(); } catch (e) { print('Gagal memuat tag: $e'); } } /// Muat akun tujuan permanen dari SharedPreferences. /// Akun tujuan dipilih sekali di Config dan dipakai semua transaksi. Future reloadDestinationFromPrefs() async { final prefs = await SharedPreferences.getInstance(); final id = prefs.getString('destination_account_id'); final name = prefs.getString('destination_account_name'); if (id != null && id.isNotEmpty) { _state = _state.copyWith( destinationAccountId: id, destinationAccountName: name, ); } } /// Memuat daftar akun dengan prioritas: server > cache > fallback Future loadAccounts() async { if (_state.fireflyUrl == null || _state.accessToken == null) { final cachedAccounts = await AccountCacheService.getCachedAccounts(); if (cachedAccounts.isNotEmpty) { _state = _state.copyWith(accounts: cachedAccounts); notifyListeners(); } return; } final allAccounts = await _getAccountsWithFallback(); _state = _state.copyWith(accounts: allAccounts); notifyListeners(); } Future>> _getAccountsWithFallback() async { if (_state.fireflyUrl == null || _state.accessToken == null) { return []; } try { final serverAccounts = await ReceiptService.loadAccounts( baseUrl: _state.fireflyUrl!, accessToken: _state.accessToken!, ); await ReceiptService.saveAccountsToMirror(serverAccounts); return serverAccounts; } catch (serverError) { print('Gagal memuat akun dari server: $serverError'); try { final mirroredAccounts = await ReceiptService.getMirroredAccounts(); if (mirroredAccounts.isNotEmpty) { return mirroredAccounts; } } catch (mirrorError) { print('Gagal memuat dari mirror: $mirrorError'); } return []; } } // ============ GRUP METHODS ============ /// Tambah grup baru void addGroup() { final id = 'group_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(1000)}'; _state = _state.copyWith( groups: [..._state.groups, ReceiptGroup(id: id)], ); notifyListeners(); } /// Hapus grup berdasarkan id void removeGroup(String groupId) { _state = _state.copyWith( groups: _state.groups.where((g) => g.id != groupId).toList(), ); notifyListeners(); } /// Pilih akun sumber untuk grup tertentu void selectGroupSource(String groupId, String id, String name) { _state = _state.copyWith( groups: _state.groups .map((g) => g.id == groupId ? g.copyWith(sourceAccountId: id, sourceAccountName: name) : g) .toList(), ); notifyListeners(); } /// Set tag untuk grup tertentu void setGroupTags(String groupId, List tags) { _state = _state.copyWith( groups: _state.groups .map((g) => g.id == groupId ? g.copyWith(tags: tags) : g) .toList(), ); notifyListeners(); } /// Tambah item ke grup tertentu void addItem(String groupId, ReceiptItem item) { _state = _state.copyWith( groups: _state.groups .map((g) => g.id == groupId ? g.copyWith(items: [...g.items, item]) : g) .toList(), ); notifyListeners(); } /// Edit item di grup tertentu void editItem(String groupId, int index, ReceiptItem newItem) { _state = _state.copyWith( groups: _state.groups.map((g) { if (g.id != groupId) return g; if (index < 0 || index >= g.items.length) return g; final updatedItems = List.from(g.items); updatedItems[index] = newItem; return g.copyWith(items: updatedItems); }).toList(), ); notifyListeners(); } /// Hapus item dari grup tertentu void removeItem(String groupId, int index) { _state = _state.copyWith( groups: _state.groups.map((g) { if (g.id != groupId) return g; if (index < 0 || index >= g.items.length) return g; final updatedItems = List.from(g.items)..removeAt(index); return g.copyWith(items: updatedItems); }).toList(), ); notifyListeners(); } /// Pulihkan item yang dihapus (untuk Undo) void restoreItem(String groupId, int index, ReceiptItem item) { _state = _state.copyWith( groups: _state.groups.map((g) { if (g.id != groupId) return g; final updatedItems = List.from(g.items); if (index >= 0 && index <= updatedItems.length) { updatedItems.insert(index, item); } else { updatedItems.add(item); } return g.copyWith(items: updatedItems); }).toList(), ); notifyListeners(); } /// Reset receipt ke kondisi awal (transaksi baru) void resetReceipt() { _state = ReceiptState( destinationAccountId: _state.destinationAccountId, destinationAccountName: _state.destinationAccountName, fireflyUrl: _state.fireflyUrl, accessToken: _state.accessToken, accounts: _state.accounts, availableTags: _state.availableTags, groups: [_newEmptyGroup()], ); notifyListeners(); } // ============ AKUN TUJUAN (permanen, dari Config) ============ /// Set akun tujuan dan persist ke SharedPreferences. void selectDestinationAccount(String id, String name) { _state = _state.copyWith( destinationAccountId: id, destinationAccountName: name, ); // Persist permanen SharedPreferences.getInstance().then((prefs) { prefs.setString('destination_account_id', id); prefs.setString('destination_account_name', name); }); notifyListeners(); } // ============ TIP ============ /// Set akun sumber untuk tip (null = bukan tip) void setTipSourceAccount(String? id, String? name) { _state = _state.copyWith( tipSourceAccountId: id, tipSourceAccountName: name, ); notifyListeners(); } /// Hapus tip void clearTip() { _state = _state.copyWith( tipSourceAccountId: null, tipSourceAccountName: null, ); notifyListeners(); } // ============ TANGGAL ============ void setTransactionDate(DateTime date) { _state = _state.copyWith(transactionDate: date); notifyListeners(); } // ============ PEMBAYARAN ============ void setPaymentAmount(double amount) { _state = _state.copyWith(paymentAmount: amount); notifyListeners(); } void setTags(String tagsStr) { final tags = tagsStr .split(',') .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); _state = _state.copyWith(tags: tags); notifyListeners(); } double get changeAmount => _state.changeAmount; double get paymentAmount => _state.paymentAmount; bool get hasTip => _state.hasTip; String? findAccountIdByName(String name, String expectedType) { return ReceiptService.findAccountIdByName( name: name, expectedType: expectedType, accounts: _state.accounts, ); } /// Mengirim transaksi ke Firefly III. /// Mengembalikan list ID transaksi (satu per grup + satu untuk tip jika ada). Future> submitTransaction() async { final groupsWithItems = _state.groups.where((g) => g.items.isNotEmpty).toList(); if (groupsWithItems.isEmpty) { throw Exception('Tidak ada item untuk dikirim'); } if (_state.fireflyUrl == null || _state.accessToken == null) { throw Exception('Kredensial Firefly III tidak ditemukan'); } if (_state.destinationAccountId == null) { throw Exception('Akun tujuan harus dipilih'); } // Validasi semua grup punya sumber for (final g in groupsWithItems) { if (!g.hasSource) { throw Exception('Setiap grup dengan item harus memiliki akun sumber'); } } final transactionIds = await ReceiptService.submitTransaction( groups: groupsWithItems, transactionDate: _state.transactionDate, destinationAccountId: _state.destinationAccountId!, accounts: _state.accounts, baseUrl: _state.fireflyUrl!, accessToken: _state.accessToken!, paymentAmount: _state.paymentAmount, tipSourceAccountId: _state.tipSourceAccountId, totalAmount: _state.total, tags: _state.tags, ); return transactionIds; } /// Simpan transaksi ke penyimpanan lokal Future saveTransactionLocally() async { final groupsWithItems = _state.groups.where((g) => g.items.isNotEmpty).toList(); if (groupsWithItems.isEmpty) { throw Exception('Tidak ada item untuk disimpan'); } if (_state.destinationAccountId == null) { throw Exception('Akun tujuan harus dipilih'); } for (final g in groupsWithItems) { if (!g.hasSource) { throw Exception('Setiap grup dengan item harus memiliki akun sumber'); } } final receiptId = 'receipt_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(1000)}'; final transactionDescription = _generateTransactionDescription(groupsWithItems); final localReceipt = LocalReceipt( id: receiptId, groups: groupsWithItems, destinationAccountId: _state.destinationAccountId, destinationAccountName: _state.destinationAccountName, transactionDate: _state.transactionDate, transactionDescription: transactionDescription, createdAt: DateTime.now(), paymentAmount: _state.paymentAmount, tipSourceAccountId: _state.tipSourceAccountId, tipSourceAccountName: _state.tipSourceAccountName, tags: _state.tags, ); await LocalReceiptService.saveReceipt(localReceipt); // Reset state (grup ke 1 grup kosong; akun tujuan permanen tidak di-reset) _state = _state.copyWith( groups: [_newEmptyGroup()], paymentAmount: 0.0, tipSourceAccountId: null, tipSourceAccountName: null, tags: [], ); notifyListeners(); return receiptId; } /// Simpan grup yang gagal dikirim sebagai nota lokal (unsubmitted), /// lalu reset state agar user mulai bersih. Grup sukses diabaikan. /// /// Dipakai oleh popup pengiriman transaksi setelah user tekan "Selesai". Future saveFailedGroupsLocally( List results) async { // Ambil grup asli dari state yang cocok dengan result failed. final failedGroupIds = results .where((r) => r.isFailed && !r.isTip) .map((r) => r.groupId) .toSet(); final hasFailedTip = results.any((r) => r.isTip && r.isFailed); if (failedGroupIds.isEmpty && !hasFailedTip) { // Tidak ada yang gagal; tetap reset state. _resetState(); return; } final failedGroups = _state.groups .where((g) => failedGroupIds.contains(g.id) && g.items.isNotEmpty) .map((g) => ReceiptGroup( id: g.id, items: List.from(g.items), sourceAccountId: g.sourceAccountId, sourceAccountName: g.sourceAccountName, tags: List.from(g.tags), )) .toList(); if (failedGroups.isNotEmpty) { final receiptId = 'receipt_${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(1000)}'; final transactionDescription = _generateTransactionDescription(failedGroups); final localReceipt = LocalReceipt( id: receiptId, groups: failedGroups, destinationAccountId: _state.destinationAccountId, destinationAccountName: _state.destinationAccountName, transactionDate: _state.transactionDate, transactionDescription: transactionDescription, createdAt: DateTime.now(), paymentAmount: _state.paymentAmount, tipSourceAccountId: hasFailedTip ? _state.tipSourceAccountId : null, tipSourceAccountName: hasFailedTip ? _state.tipSourceAccountName : null, tags: _state.tags, ); await LocalReceiptService.saveReceipt(localReceipt); } _resetState(); } /// Reset state ke kosong (grup, akun tujuan, pembayaran, tip). /// Catatan: destinationAccountId tidak di-reset di sini karena akun tujuan /// bersifat permanen (dari Config). Grup di-reset ke 1 grup kosong. void _resetState() { _state = _state.copyWith( groups: [_newEmptyGroup()], paymentAmount: 0.0, tipSourceAccountId: null, tipSourceAccountName: null, tags: [], ); notifyListeners(); } /// 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'); } final result = await LocalReceiptService.submitAllUnsubmittedReceipts( _state.fireflyUrl!, _state.accessToken!, ); notifyListeners(); return result; } String _generateTransactionDescription(List groups) { final allItems = groups.expand((g) => g.items).toList(); if (allItems.isEmpty) { return 'Transaksi Struk Belanja'; } final itemNames = allItems.take(5).map((item) => item.description).toList(); if (allItems.length > 5) { itemNames[4] += ', dll'; } return itemNames.join(', '); } /// Load receipt data to current state for editing void loadReceiptForEdit(LocalReceipt receipt) { _state = _state.copyWith( groups: receipt.groups .map((g) => ReceiptGroup( id: g.id, items: List.from(g.items), sourceAccountId: g.sourceAccountId, sourceAccountName: g.sourceAccountName, tags: List.from(g.tags), )) .toList(), destinationAccountId: receipt.destinationAccountId, destinationAccountName: receipt.destinationAccountName, transactionDate: receipt.transactionDate, paymentAmount: receipt.paymentAmount, tipSourceAccountId: receipt.tipSourceAccountId, tipSourceAccountName: receipt.tipSourceAccountName, tags: receipt.tags, ); notifyListeners(); } /// Show payment dialog /// Show payment dialog with quick cash buttons and reactive change calculation Future showPaymentTipDialog(BuildContext context) async { double localPaymentAmount = _state.paymentAmount; final total = _state.total; String fmt(num v) => v.abs().toStringAsFixed(0).replaceAllMapped( RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.'); final controller = TextEditingController( text: localPaymentAmount > 0 ? fmt(localPaymentAmount) : '', ); await showDialog( context: context, builder: (BuildContext dialogContext) { return AlertDialog( title: const Row( children: [ Icon(Icons.payments_outlined, color: Colors.green, size: 22), SizedBox(width: 8), Text('Pembayaran Tunai'), ], ), content: StatefulBuilder( builder: (BuildContext context, StateSetter setModalState) { final change = localPaymentAmount - total; final isSufficient = localPaymentAmount >= total && total > 0; void setAmount(double amt) { localPaymentAmount = amt; controller.text = amt > 0 ? fmt(amt) : ''; setModalState(() {}); } // Hitung pembulatan ke atas terdekat (kelipatan 10rb atau 50rb) final round10k = total > 0 ? ((total / 10000).ceil() * 10000).toDouble() : 0.0; final round50k = total > 0 ? ((total / 50000).ceil() * 50000).toDouble() : 0.0; return SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Total Tagihan Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(6), border: Border.all(color: Colors.grey[300]!), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'TOTAL TAGIHAN:', style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, ), ), Text( 'Rp ${fmt(total)}', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.primary, ), ), ], ), ), const SizedBox(height: 14), // Input Jumlah Bayar TextField( controller: controller, decoration: const InputDecoration( labelText: 'Jumlah Uang Diterima', prefixText: 'Rp ', border: OutlineInputBorder(), isDense: true, ), keyboardType: TextInputType.number, onChanged: (value) { final clean = value.replaceAll('.', '').replaceAll(',', '').trim(); final val = double.tryParse(clean) ?? 0.0; localPaymentAmount = val; final formatted = val > 0 ? fmt(val) : ''; if (formatted != value && clean.isNotEmpty) { controller.value = TextEditingValue( text: formatted, selection: TextSelection.collapsed(offset: formatted.length), ); } setModalState(() {}); }, ), const SizedBox(height: 10), // Tombol Bantuan Pecahan Uang Cepat const Text( 'Pilihan Pecahan Uang:', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.grey), ), const SizedBox(height: 4), Wrap( spacing: 6, runSpacing: 4, children: [ if (total > 0) ActionChip( avatar: const Icon(Icons.check_circle_outline, size: 14, color: Colors.green), label: const Text('Uang Pas', style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold)), onPressed: () => setAmount(total), backgroundColor: Colors.green.shade50, visualDensity: VisualDensity.compact, ), if (round10k > total && round10k != round50k) ActionChip( label: Text('Rp ${fmt(round10k)}', style: const TextStyle(fontSize: 11)), onPressed: () => setAmount(round10k), visualDensity: VisualDensity.compact, ), if (round50k > total) ActionChip( label: Text('Rp ${fmt(round50k)}', style: const TextStyle(fontSize: 11)), onPressed: () => setAmount(round50k), visualDensity: VisualDensity.compact, ), if (total < 50000 && round50k != 50000) ActionChip( label: const Text('50.000', style: TextStyle(fontSize: 11)), onPressed: () => setAmount(50000), visualDensity: VisualDensity.compact, ), if (total < 100000 && round50k != 100000) ActionChip( label: const Text('100.000', style: TextStyle(fontSize: 11)), onPressed: () => setAmount(100000), visualDensity: VisualDensity.compact, ), ], ), const SizedBox(height: 12), // Kotak Kembalian Live if (isSufficient && change >= 0) ...[ Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: Colors.green[50], borderRadius: BorderRadius.circular(6), border: Border.all(color: Colors.green.shade400, width: 1.5), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'KEMBALIAN:', style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, color: Colors.green, ), ), Text( 'Rp ${fmt(change)}', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Colors.green, ), ), ], ), ), ] else if (localPaymentAmount > 0 && localPaymentAmount < total) ...[ Container( padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Colors.red[50], borderRadius: BorderRadius.circular(4), border: Border.all(color: Colors.red.shade300), ), child: Text( 'Uang kurang: Rp ${fmt(total - localPaymentAmount)}', style: const TextStyle(fontSize: 12, color: Colors.red, fontWeight: FontWeight.bold), ), ), ], ], ), ); }, ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Batal'), ), ElevatedButton( onPressed: () { setPaymentAmount(localPaymentAmount); if (localPaymentAmount <= _state.total) { clearTip(); } Navigator.of(dialogContext).pop(); }, child: const Text('Simpan'), ), ], ); }, ); } }