// 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: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(); 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(); } } /// 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(); } /// 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(); } // ============ 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(); } 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, ); 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, ); 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, ); 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, )) .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, ); 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, ); 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, )) .toList(), destinationAccountId: receipt.destinationAccountId, destinationAccountName: receipt.destinationAccountName, transactionDate: receipt.transactionDate, paymentAmount: receipt.paymentAmount, tipSourceAccountId: receipt.tipSourceAccountId, tipSourceAccountName: receipt.tipSourceAccountName, ); notifyListeners(); } /// Show payment dialog Future showPaymentTipDialog(BuildContext context) async { double localPaymentAmount = _state.paymentAmount; await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: const Text('Pembayaran'), content: StatefulBuilder( builder: (BuildContext context, StateSetter setState) { return Column( mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(4), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'TOTAL:', style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, ), ), Text( _state.total .toStringAsFixed(0) .replaceAllMapped( RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},'), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, ), ), ], ), ), const SizedBox(height: 16), TextField( decoration: const InputDecoration( labelText: 'Jumlah Bayar', hintText: 'Masukkan jumlah uang yang dibayar', labelStyle: TextStyle(fontSize: 14), hintStyle: TextStyle(fontSize: 14, color: Colors.grey), ), keyboardType: TextInputType.number, style: const TextStyle(fontSize: 14), onChanged: (value) { localPaymentAmount = double.tryParse(value) ?? 0.0; }, ), const SizedBox(height: 8), if (localPaymentAmount > 0 && localPaymentAmount >= _state.total) Container( padding: const EdgeInsets.all(8.0), decoration: BoxDecoration( color: Colors.green[50], borderRadius: BorderRadius.circular(4), border: Border.all(color: Colors.green), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( 'KEMBALI:', style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, ), ), Text( (localPaymentAmount - _state.total) .toStringAsFixed(0) .replaceAllMapped( RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},'), style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: Colors.green, ), ), ], ), ), const SizedBox(height: 8), const Text( 'Tip dapat diatur di bagian bawah struk setelah memasukkan jumlah bayar.', style: TextStyle(fontSize: 11, color: Colors.grey), textAlign: TextAlign.center, ), ], ); }, ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Batal'), ), TextButton( onPressed: () { setPaymentAmount(localPaymentAmount); // Jika pembayaran tidak mencukupi/tidak ada kembalian, hapus tip if (localPaymentAmount <= _state.total) { clearTip(); } Navigator.of(context).pop(); }, child: const Text('Simpan'), ), ], ); }, ); } }