diff --git a/frontend/pshared/lib/api/requests/ledger/create.dart b/frontend/pshared/lib/api/requests/ledger/create.dart index dc1ae3c9..d385b0c2 100644 --- a/frontend/pshared/lib/api/requests/ledger/create.dart +++ b/frontend/pshared/lib/api/requests/ledger/create.dart @@ -6,7 +6,6 @@ import 'package:pshared/data/dto/ledger/type.dart'; part 'create.g.dart'; - @JsonSerializable() class CreateLedgerAccountRequest { final Map? metadata; @@ -27,7 +26,7 @@ class CreateLedgerAccountRequest { this.ownerRef, }); - factory CreateLedgerAccountRequest.fromJson(Map json) => _$CreateLedgerAccountRequestFromJson(json); + factory CreateLedgerAccountRequest.fromJson(Map json) => + _$CreateLedgerAccountRequestFromJson(json); Map toJson() => _$CreateLedgerAccountRequestToJson(this); - } diff --git a/frontend/pshared/lib/controllers/payment/source.dart b/frontend/pshared/lib/controllers/payment/source.dart new file mode 100644 index 00000000..d75d6a25 --- /dev/null +++ b/frontend/pshared/lib/controllers/payment/source.dart @@ -0,0 +1,159 @@ +import 'package:flutter/foundation.dart'; + +import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/models/ledger/account.dart'; +import 'package:pshared/models/payment/source_type.dart'; +import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/provider/ledger.dart'; +import 'package:pshared/utils/currency.dart'; + + +class PaymentSourceController with ChangeNotifier { + WalletsController? _walletsController; + + List _wallets = const []; + Map _walletsById = const {}; + + List _ledgerAccounts = const []; + Map _ledgerByRef = const {}; + + PaymentSourceType? _selectedType; + String? _selectedRef; + + List get wallets => _wallets; + List get ledgerAccounts => _ledgerAccounts; + bool get hasSources => _wallets.isNotEmpty || _ledgerAccounts.isNotEmpty; + + PaymentSourceType? get selectedType => _selectedType; + String? get selectedRef => _selectedRef; + + Wallet? get selectedWallet { + if (_selectedType != PaymentSourceType.wallet) return null; + final ref = _selectedRef; + if (ref == null) return null; + return _walletsById[ref]; + } + + LedgerAccount? get selectedLedgerAccount { + if (_selectedType != PaymentSourceType.ledger) return null; + final ref = _selectedRef; + if (ref == null) return null; + return _ledgerByRef[ref]; + } + + String? get selectedCurrencyCode { + final wallet = selectedWallet; + if (wallet != null) { + return currencyCodeToString(wallet.currency); + } + + final ledger = selectedLedgerAccount; + if (ledger != null) { + final code = ledger.currency.trim().toUpperCase(); + return code.isEmpty ? null : code; + } + + return null; + } + + void update(WalletsController wallets, LedgerAccountsProvider ledger) { + _walletsController = wallets; + + _walletsById = _uniqueWalletsById(wallets.wallets); + _wallets = _walletsById.values.toList(growable: false); + + _ledgerByRef = _uniqueLedgerByRef(ledger.accounts); + _ledgerAccounts = _ledgerByRef.values.toList(growable: false); + + _syncSelection(); + notifyListeners(); + } + + void selectWallet(Wallet wallet) { + selectWalletByRef(wallet.id); + } + + void selectWalletByRef(String walletRef) { + if (!_walletsById.containsKey(walletRef)) return; + _walletsController?.selectWalletByRef(walletRef); + _setSelection(PaymentSourceType.wallet, walletRef); + } + + void selectLedgerByRef(String ledgerAccountRef) { + if (!_ledgerByRef.containsKey(ledgerAccountRef)) return; + _setSelection(PaymentSourceType.ledger, ledgerAccountRef); + } + + bool isWalletSelected(String walletRef) { + return _selectedType == PaymentSourceType.wallet && + _selectedRef == walletRef; + } + + bool isLedgerSelected(String ledgerAccountRef) { + return _selectedType == PaymentSourceType.ledger && + _selectedRef == ledgerAccountRef; + } + + void _syncSelection() { + final currentType = _selectedType; + final currentRef = _selectedRef; + + if (currentType == PaymentSourceType.wallet && + currentRef != null && + _walletsById.containsKey(currentRef)) { + return; + } + + if (currentType == PaymentSourceType.ledger && + currentRef != null && + _ledgerByRef.containsKey(currentRef)) { + return; + } + + final selectedWalletRef = _walletsController?.selectedWalletRef; + if (selectedWalletRef != null && + _walletsById.containsKey(selectedWalletRef)) { + _selectedType = PaymentSourceType.wallet; + _selectedRef = selectedWalletRef; + return; + } + + if (_wallets.isNotEmpty) { + _selectedType = PaymentSourceType.wallet; + _selectedRef = _wallets.first.id; + return; + } + + if (_ledgerAccounts.isNotEmpty) { + _selectedType = PaymentSourceType.ledger; + _selectedRef = _ledgerAccounts.first.ledgerAccountRef; + return; + } + + _selectedType = null; + _selectedRef = null; + } + + void _setSelection(PaymentSourceType type, String ref) { + if (_selectedType == type && _selectedRef == ref) return; + _selectedType = type; + _selectedRef = ref; + notifyListeners(); + } + + Map _uniqueWalletsById(List wallets) { + final result = {}; + for (final wallet in wallets) { + result.putIfAbsent(wallet.id, () => wallet); + } + return result; + } + + Map _uniqueLedgerByRef(List accounts) { + final result = {}; + for (final account in accounts) { + result.putIfAbsent(account.ledgerAccountRef, () => account); + } + return result; + } +} diff --git a/frontend/pshared/lib/models/payment/intent.dart b/frontend/pshared/lib/models/payment/intent.dart index 4a722606..59cc631f 100644 --- a/frontend/pshared/lib/models/payment/intent.dart +++ b/frontend/pshared/lib/models/payment/intent.dart @@ -8,7 +8,9 @@ import 'package:pshared/models/payment/settlement_mode.dart'; class PaymentIntent { final PaymentKind kind; + final String? sourceRef; final PaymentMethodData? source; + final String? destinationRef; final PaymentMethodData? destination; final Money? amount; final FxIntent? fx; @@ -19,7 +21,9 @@ class PaymentIntent { const PaymentIntent({ this.kind = PaymentKind.unspecified, + this.sourceRef, this.source, + this.destinationRef, this.destination, this.amount, this.fx, diff --git a/frontend/pshared/lib/models/payment/source_type.dart b/frontend/pshared/lib/models/payment/source_type.dart new file mode 100644 index 00000000..b607a14d --- /dev/null +++ b/frontend/pshared/lib/models/payment/source_type.dart @@ -0,0 +1 @@ +enum PaymentSourceType { wallet, ledger } diff --git a/frontend/pshared/lib/provider/ledger.dart b/frontend/pshared/lib/provider/ledger.dart index 66c6fb7d..02209421 100644 --- a/frontend/pshared/lib/provider/ledger.dart +++ b/frontend/pshared/lib/provider/ledger.dart @@ -15,7 +15,6 @@ import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/ledger.dart'; import 'package:pshared/utils/exception.dart'; - class LedgerAccountsProvider with ChangeNotifier { final LedgerService _service; OrganizationsProvider? _organizations; @@ -25,7 +24,9 @@ class LedgerAccountsProvider with ChangeNotifier { Resource> _resource = Resource(data: []); Resource> get resource => _resource; - List get accounts => (_resource.data ?? []).where((la) => la.role == LedgerAccountRole.operating).toList(); + List get accounts => (_resource.data ?? []) + .where((la) => la.role == LedgerAccountRole.operating) + .toList(); bool get isLoading => _resource.isLoading; Exception? get error => _resource.error; @@ -33,11 +34,13 @@ class LedgerAccountsProvider with ChangeNotifier { bool get isRefreshingBalances => _isRefreshingBalances; final Set _refreshingAccounts = {}; - bool isWalletRefreshing(String ledgerAccountRef) => _refreshingAccounts.contains(ledgerAccountRef); + bool isWalletRefreshing(String ledgerAccountRef) => + _refreshingAccounts.contains(ledgerAccountRef); // Expose current org id so UI controller can reset per-org state if needed. - String? get organizationRef => - (_organizations?.isOrganizationSet ?? false) ? _organizations!.current.id : null; + String? get organizationRef => (_organizations?.isOrganizationSet ?? false) + ? _organizations!.current.id + : null; // Used to ignore stale async results (org changes / overlapping requests). int _opSeq = 0; @@ -69,7 +72,10 @@ class LedgerAccountsProvider with ChangeNotifier { _isRefreshingBalances = false; _refreshingAccounts.clear(); - _applyResource(_resource.copyWith(isLoading: true, error: null), notify: true); + _applyResource( + _resource.copyWith(isLoading: true, error: null), + notify: true, + ); try { final base = await _service.list(orgRef); @@ -78,7 +84,11 @@ class LedgerAccountsProvider with ChangeNotifier { if (seq != _opSeq) return; _applyResource( - Resource>(data: result.wallets, isLoading: false, error: result.error), + Resource>( + data: result.wallets, + isLoading: false, + error: result.error, + ), notify: true, ); } catch (e) { @@ -129,7 +139,9 @@ class LedgerAccountsProvider with ChangeNotifier { if (_refreshingAccounts.contains(ledgerAccountRef)) return; - final existing = accounts.firstWhereOrNull((w) => w.ledgerAccountRef == ledgerAccountRef); + final existing = accounts.firstWhereOrNull( + (w) => w.ledgerAccountRef == ledgerAccountRef, + ); if (existing == null) return; final orgRef = org.current.id; @@ -141,12 +153,15 @@ class LedgerAccountsProvider with ChangeNotifier { try { final balance = await _service.getBalance( - organizationRef: orgRef, + organizationRef: orgRef, ledgerAccountRef: ledgerAccountRef, ); if ((_accountSeq[ledgerAccountRef] ?? 0) != seq) return; - final next = _replaceWallet(ledgerAccountRef, (w) => w.copyWith(balance: balance)); + final next = _replaceWallet( + ledgerAccountRef, + (w) => w.copyWith(balance: balance), + ); if (next == null) return; _applyResource(_resource.copyWith(data: next), notify: false); @@ -170,7 +185,10 @@ class LedgerAccountsProvider with ChangeNotifier { final org = _organizations; if (org == null || !org.isOrganizationSet) return; - _applyResource(_resource.copyWith(isLoading: true, error: null), notify: true); + _applyResource( + _resource.copyWith(isLoading: true, error: null), + notify: true, + ); try { await _service.create( @@ -181,20 +199,31 @@ class LedgerAccountsProvider with ChangeNotifier { ); await loadAccountsWithBalances(); } catch (e) { - _applyResource(_resource.copyWith(isLoading: false, error: toException(e)), notify: true); + _applyResource( + _resource.copyWith(isLoading: false, error: toException(e)), + notify: true, + ); rethrow; } } // ---------- internals ---------- - void _applyResource(Resource> newResource, {required bool notify}) { + void _applyResource( + Resource> newResource, { + required bool notify, + }) { _resource = newResource; if (notify) notifyListeners(); } - List? _replaceWallet(String ledgerAccountRef, LedgerAccount Function(LedgerAccount) updater) { - final idx = accounts.indexWhere((w) => w.ledgerAccountRef == ledgerAccountRef); + List? _replaceWallet( + String ledgerAccountRef, + LedgerAccount Function(LedgerAccount) updater, + ) { + final idx = accounts.indexWhere( + (w) => w.ledgerAccountRef == ledgerAccountRef, + ); if (idx < 0) return null; final next = List.from(accounts); @@ -202,7 +231,10 @@ class LedgerAccountsProvider with ChangeNotifier { return next; } - Future<_LedgerAccountLoadResult> _withBalances(String orgRef, List base) async { + Future<_LedgerAccountLoadResult> _withBalances( + String orgRef, + List base, + ) async { Exception? firstError; final withBalances = await _mapConcurrent( @@ -211,7 +243,7 @@ class LedgerAccountsProvider with ChangeNotifier { (ledgerAccount) async { try { final balance = await _service.getBalance( - organizationRef: orgRef, + organizationRef: orgRef, ledgerAccountRef: ledgerAccount.ledgerAccountRef, ); return ledgerAccount.copyWith(balance: balance); @@ -243,7 +275,10 @@ class LedgerAccountsProvider with ChangeNotifier { } } - final workers = List.generate(min(concurrency, items.length), (_) => worker()); + final workers = List.generate( + min(concurrency, items.length), + (_) => worker(), + ); await Future.wait(workers); return results.cast(); diff --git a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart index e72a70fa..50e57ae0 100644 --- a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart +++ b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart @@ -1,13 +1,17 @@ -import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/models/payment/asset.dart'; import 'package:pshared/models/payment/chain_network.dart'; import 'package:pshared/models/payment/customer.dart'; +import 'package:pshared/models/payment/currency_pair.dart'; import 'package:pshared/models/payment/fees/treatment.dart'; +import 'package:pshared/models/payment/fx/intent.dart'; +import 'package:pshared/models/payment/fx/side.dart'; import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/methods/card.dart'; import 'package:pshared/models/payment/methods/crypto_address.dart'; import 'package:pshared/models/payment/methods/data.dart'; import 'package:pshared/models/payment/methods/iban.dart'; +import 'package:pshared/models/payment/methods/ledger.dart'; import 'package:pshared/models/payment/methods/managed_wallet.dart'; import 'package:pshared/models/payment/methods/russian_bank.dart'; import 'package:pshared/models/payment/methods/type.dart'; @@ -18,7 +22,6 @@ import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/recipient/provider.dart'; -import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/payment/fx_helpers.dart'; class QuotationIntentBuilder { @@ -26,21 +29,23 @@ class QuotationIntentBuilder { PaymentIntent? build({ required PaymentAmountProvider payment, - required WalletsController wallets, + required PaymentSourceController source, required PaymentFlowProvider flow, required RecipientsProvider recipients, }) { - final selectedWallet = wallets.selectedWallet; + final sourceMethod = _resolveSourceMethod(source); + final sourceCurrency = source.selectedCurrencyCode; final paymentData = flow.selectedPaymentData; final selectedMethod = flow.selectedMethod; - if (selectedWallet == null || paymentData == null) return null; + if (sourceMethod == null || sourceCurrency == null || paymentData == null) { + return null; + } final customer = _buildCustomer( recipient: recipients.currentObject, method: selectedMethod, data: paymentData, ); - final sourceCurrency = currencyCodeToString(selectedWallet.currency); final amountCurrency = payment.settlementMode == SettlementMode.fixReceived ? _settlementCurrency : sourceCurrency; @@ -48,26 +53,22 @@ class QuotationIntentBuilder { amount: payment.amount.toString(), currency: amountCurrency, ); + final isLedgerSource = source.selectedLedgerAccount != null; final isCryptoToCrypto = paymentData is CryptoAddressPaymentMethod && (paymentData.asset?.tokenSymbol ?? '').trim().toUpperCase() == amount.currency; - final fxIntent = FxIntentHelper.buildSellBaseBuyQuote( - baseCurrency: sourceCurrency, - quoteCurrency: _settlementCurrency, // TODO: exentd target currencies + final fxIntent = _buildFxIntent( + sourceCurrency: sourceCurrency, + settlementMode: payment.settlementMode, + isLedgerSource: isLedgerSource, enabled: !isCryptoToCrypto, ); return PaymentIntent( kind: PaymentKind.payout, amount: amount, destination: paymentData, - source: ManagedWalletPaymentMethod( - managedWalletRef: selectedWallet.id, - asset: PaymentAsset( - tokenSymbol: selectedWallet.tokenSymbol ?? '', - chain: selectedWallet.network ?? ChainNetwork.unspecified, - ), - ), + source: sourceMethod, fx: fxIntent, feeTreatment: payment.payerCoversFee ? FeeTreatment.addToSource @@ -77,6 +78,56 @@ class QuotationIntentBuilder { ); } + FxIntent? _buildFxIntent({ + required String sourceCurrency, + required SettlementMode settlementMode, + required bool isLedgerSource, + required bool enabled, + }) { + if (!enabled) return null; + + // Ledger sources in fix_received mode need explicit reverse side. + // BFF maps only settlement currency + fx side, then quotation derives pair. + // For ledger this preserves source debit in ledger currency (e.g. USDT). + if (isLedgerSource && settlementMode == SettlementMode.fixReceived) { + final base = sourceCurrency.trim(); + final quote = _settlementCurrency; + if (base.isEmpty || base.toUpperCase() == quote.toUpperCase()) { + return null; + } + return FxIntent( + pair: CurrencyPair(base: base, quote: quote), + side: FxSide.buyBaseSellQuote, + ); + } + + return FxIntentHelper.buildSellBaseBuyQuote( + baseCurrency: sourceCurrency, + quoteCurrency: _settlementCurrency, // TODO: exentd target currencies + enabled: true, + ); + } + + PaymentMethodData? _resolveSourceMethod(PaymentSourceController source) { + final wallet = source.selectedWallet; + if (wallet != null) { + return ManagedWalletPaymentMethod( + managedWalletRef: wallet.id, + asset: PaymentAsset( + tokenSymbol: wallet.tokenSymbol ?? '', + chain: wallet.network ?? ChainNetwork.unspecified, + ), + ); + } + + final ledger = source.selectedLedgerAccount; + if (ledger != null) { + return LedgerPaymentMethod(ledgerAccountRef: ledger.ledgerAccountRef); + } + + return null; + } + Customer? _buildCustomer({ required Recipient? recipient, required PaymentMethod? method, diff --git a/frontend/pshared/lib/provider/payment/quotation/quotation.dart b/frontend/pshared/lib/provider/payment/quotation/quotation.dart index 08bc9c06..c48e4828 100644 --- a/frontend/pshared/lib/provider/payment/quotation/quotation.dart +++ b/frontend/pshared/lib/provider/payment/quotation/quotation.dart @@ -7,7 +7,7 @@ import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; import 'package:pshared/api/requests/payment/quote.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/data/mapper/payment/intent/payment.dart'; import 'package:pshared/models/asset.dart'; import 'package:pshared/models/payment/intent.dart'; @@ -36,6 +36,7 @@ class QuotationProvider extends ChangeNotifier { late OrganizationsProvider _organizations; bool _isLoaded = false; PaymentIntent? _lastIntent; + String? _sourceCurrencyCode; final QuotationIntentBuilder _intentBuilder = QuotationIntentBuilder(); final AutoRefreshScheduler _autoRefresh = AutoRefreshScheduler(); AutoRefreshMode _autoRefreshMode = AutoRefreshMode.on; @@ -43,15 +44,16 @@ class QuotationProvider extends ChangeNotifier { void update( OrganizationsProvider venue, PaymentAmountProvider payment, - WalletsController wallets, + PaymentSourceController source, PaymentFlowProvider flow, RecipientsProvider recipients, PaymentMethodsProvider _, ) { _organizations = venue; + _sourceCurrencyCode = source.selectedCurrencyCode; final intent = _intentBuilder.build( payment: payment, - wallets: wallets, + source: source, flow: flow, recipients: recipients, ); @@ -77,7 +79,12 @@ class QuotationProvider extends ChangeNotifier { } Asset? get fee => _assetFromMoney(quoteFeeTotal(quotation)); - Asset? get total => _assetFromMoney(quotation?.amounts?.sourceDebitTotal); + Asset? get total => _assetFromMoney( + quoteSourceDebitTotal( + quotation, + preferredSourceCurrency: _sourceCurrencyCode, + ), + ); Asset? get recipientGets => _assetFromMoney(quotation?.amounts?.destinationSettlement); @@ -139,6 +146,7 @@ class QuotationProvider extends ChangeNotifier { void reset() { _isLoaded = false; _lastIntent = null; + _sourceCurrencyCode = null; _setResource(Resource(data: null, isLoading: false, error: null)); } diff --git a/frontend/pshared/lib/provider/payment/wallets.dart b/frontend/pshared/lib/provider/payment/wallets.dart index 107ab91d..9c2ce2e2 100644 --- a/frontend/pshared/lib/provider/payment/wallets.dart +++ b/frontend/pshared/lib/provider/payment/wallets.dart @@ -13,7 +13,6 @@ import 'package:pshared/provider/resource.dart'; import 'package:pshared/service/payment/wallets.dart'; import 'package:pshared/utils/exception.dart'; - class WalletsProvider with ChangeNotifier { final WalletsService _service; OrganizationsProvider? _organizations; @@ -31,11 +30,13 @@ class WalletsProvider with ChangeNotifier { bool get isRefreshingBalances => _isRefreshingBalances; final Set _refreshingWallets = {}; - bool isWalletRefreshing(String walletRef) => _refreshingWallets.contains(walletRef); + bool isWalletRefreshing(String walletRef) => + _refreshingWallets.contains(walletRef); // Expose current org id so UI controller can reset per-org state if needed. - String? get organizationRef => - (_organizations?.isOrganizationSet ?? false) ? _organizations!.current.id : null; + String? get organizationRef => (_organizations?.isOrganizationSet ?? false) + ? _organizations!.current.id + : null; // Used to ignore stale async results (org changes / overlapping requests). int _opSeq = 0; @@ -67,13 +68,25 @@ class WalletsProvider with ChangeNotifier { _isRefreshingBalances = false; _refreshingWallets.clear(); - _applyResource(_resource.copyWith(isLoading: true, error: null), notify: true); + _applyResource( + _resource.copyWith(isLoading: true, error: null), + notify: true, + ); try { final base = await _service.getWallets(orgId); + if (seq != _opSeq) return; + + // Publish wallets as soon as the list is available, then hydrate balances. + _isRefreshingBalances = true; + _applyResource( + Resource>(data: base, isLoading: false, error: null), + notify: true, + ); final result = await _withBalances(orgId, base); if (seq != _opSeq) return; + _isRefreshingBalances = false; _applyResource( Resource>( @@ -85,6 +98,7 @@ class WalletsProvider with ChangeNotifier { ); } catch (e) { if (seq != _opSeq) return; + _isRefreshingBalances = false; _applyResource( _resource.copyWith(isLoading: false, error: toException(e)), @@ -145,7 +159,10 @@ class WalletsProvider with ChangeNotifier { final balance = await _service.getBalance(orgId, walletRef); if ((_walletSeq[walletRef] ?? 0) != seq) return; - final next = _replaceWallet(walletRef, (w) => w.copyWith(balance: balance)); + final next = _replaceWallet( + walletRef, + (w) => w.copyWith(balance: balance), + ); if (next == null) return; _applyResource(_resource.copyWith(data: next), notify: false); @@ -169,7 +186,10 @@ class WalletsProvider with ChangeNotifier { final org = _organizations; if (org == null || !org.isOrganizationSet) return; - _applyResource(_resource.copyWith(isLoading: true, error: null), notify: true); + _applyResource( + _resource.copyWith(isLoading: true, error: null), + notify: true, + ); try { await _service.create( @@ -180,19 +200,28 @@ class WalletsProvider with ChangeNotifier { ); await loadWalletsWithBalances(); } catch (e) { - _applyResource(_resource.copyWith(isLoading: false, error: toException(e)), notify: true); + _applyResource( + _resource.copyWith(isLoading: false, error: toException(e)), + notify: true, + ); rethrow; } } // ---------- internals ---------- - void _applyResource(Resource> newResource, {required bool notify}) { + void _applyResource( + Resource> newResource, { + required bool notify, + }) { _resource = newResource; if (notify) notifyListeners(); } - List? _replaceWallet(String walletRef, Wallet Function(Wallet) updater) { + List? _replaceWallet( + String walletRef, + Wallet Function(Wallet) updater, + ) { final idx = wallets.indexWhere((w) => w.id == walletRef); if (idx < 0) return null; @@ -201,7 +230,10 @@ class WalletsProvider with ChangeNotifier { return next; } - Future<_WalletLoadResult> _withBalances(String orgRef, List base) async { + Future<_WalletLoadResult> _withBalances( + String orgRef, + List base, + ) async { Exception? firstError; final withBalances = await _mapConcurrent( @@ -239,7 +271,10 @@ class WalletsProvider with ChangeNotifier { } } - final workers = List.generate(min(concurrency, items.length), (_) => worker()); + final workers = List.generate( + min(concurrency, items.length), + (_) => worker(), + ); await Future.wait(workers); return results.cast(); diff --git a/frontend/pshared/lib/utils/payment/quote_helpers.dart b/frontend/pshared/lib/utils/payment/quote_helpers.dart index 99bbcbda..3b782f75 100644 --- a/frontend/pshared/lib/utils/payment/quote_helpers.dart +++ b/frontend/pshared/lib/utils/payment/quote_helpers.dart @@ -4,6 +4,7 @@ import 'package:pshared/models/payment/quote/quote.dart'; import 'package:pshared/utils/currency.dart'; import 'package:pshared/utils/money.dart'; + Money? quoteFeeTotal(PaymentQuote? quote) { final preferredCurrency = quote?.amounts?.sourcePrincipal?.currency ?? @@ -14,6 +15,36 @@ Money? quoteFeeTotal(PaymentQuote? quote) { ); } +Money? quoteSourceDebitTotal( + PaymentQuote? quote, { + String? preferredSourceCurrency, +}) { + final sourceDebitTotal = quote?.amounts?.sourceDebitTotal; + final preferredCurrency = _normalizeCurrency( + preferredSourceCurrency ?? quote?.amounts?.sourcePrincipal?.currency, + ); + + if (sourceDebitTotal == null) { + return _rebuildSourceDebitTotal( + quote, + preferredSourceCurrency: preferredCurrency, + ); + } + + final debitCurrency = _normalizeCurrency(sourceDebitTotal.currency); + if (preferredCurrency == null || + debitCurrency == null || + debitCurrency == preferredCurrency) { + return sourceDebitTotal; + } + + final rebuilt = _rebuildSourceDebitTotal( + quote, + preferredSourceCurrency: preferredCurrency, + ); + return rebuilt ?? sourceDebitTotal; +} + Money? quoteFeeTotalFromLines( List? lines, { String? preferredCurrency, @@ -74,6 +105,44 @@ List aggregateMoneyByCurrency(Iterable values) { .toList(); } +Money? _rebuildSourceDebitTotal( + PaymentQuote? quote, { + String? preferredSourceCurrency, +}) { + final sourcePrincipal = quote?.amounts?.sourcePrincipal; + if (sourcePrincipal == null) return null; + + final principalCurrency = _normalizeCurrency(sourcePrincipal.currency); + if (principalCurrency == null) return null; + if (preferredSourceCurrency != null && + principalCurrency != preferredSourceCurrency) { + return null; + } + + final principalAmount = parseMoneyAmount( + sourcePrincipal.amount, + fallback: double.nan, + ); + if (principalAmount.isNaN) return null; + + double totalAmount = principalAmount; + final fee = quoteFeeTotalFromLines( + quote?.fees?.lines, + preferredCurrency: principalCurrency, + ); + if (fee != null && _normalizeCurrency(fee.currency) == principalCurrency) { + final feeAmount = parseMoneyAmount(fee.amount, fallback: double.nan); + if (!feeAmount.isNaN) { + totalAmount += feeAmount; + } + } + + return Money( + amount: amountToString(totalAmount), + currency: principalCurrency, + ); +} + double _lineSign(String? side) { final normalized = side?.trim().toLowerCase() ?? ''; switch (normalized) { diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 97f882fe..76baec6b 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -5,8 +5,10 @@ import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/ledger.dart'; import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/provider/payment/flow.dart'; @@ -49,7 +51,6 @@ import 'package:pweb/services/payments/csv_input.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - RouteBase payoutShellRoute() => ShellRoute( builder: (context, state, child) => MultiProvider( providers: [ @@ -82,10 +83,19 @@ RouteBase payoutShellRoute() => ShellRoute( provider!..update(recipients, methods), ), ChangeNotifierProvider(create: (_) => PaymentAmountProvider()), + ChangeNotifierProxyProvider2< + WalletsController, + LedgerAccountsProvider, + PaymentSourceController + >( + create: (_) => PaymentSourceController(), + update: (_, wallets, ledger, controller) => + controller!..update(wallets, ledger), + ), ChangeNotifierProxyProvider6< OrganizationsProvider, PaymentAmountProvider, - WalletsController, + PaymentSourceController, PaymentFlowProvider, RecipientsProvider, PaymentMethodsProvider, @@ -97,7 +107,7 @@ RouteBase payoutShellRoute() => ShellRoute( _, organization, payment, - wallet, + source, flow, recipients, methods, @@ -106,7 +116,7 @@ RouteBase payoutShellRoute() => ShellRoute( ..update( organization, payment, - wallet, + source, flow, recipients, methods, @@ -212,23 +222,12 @@ RouteBase payoutShellRoute() => ShellRoute( path: routerPage(Pages.dashboard), pageBuilder: (context, _) => NoTransitionPage( child: DashboardPage( - onRecipientSelected: (recipient) => _startPayment( - context, - recipient: recipient, - ), - onGoToPaymentWithoutRecipient: (type) => _startPayment( - context, - recipient: null, - paymentType: type, - ), - onTopUp: (wallet) => _openWalletTopUp( - context, - wallet, - ), - onWalletTap: (wallet) => _openWalletEdit( - context, - wallet, - ), + onRecipientSelected: (recipient) => + _startPayment(context, recipient: recipient), + onGoToPaymentWithoutRecipient: (type) => + _startPayment(context, recipient: null, paymentType: type), + onTopUp: (wallet) => _openWalletTopUp(context, wallet), + onWalletTap: (wallet) => _openWalletEdit(context, wallet), ), ), ), @@ -239,10 +238,8 @@ RouteBase payoutShellRoute() => ShellRoute( final loc = AppLocalizations.of(context)!; return NoTransitionPage( child: RecipientAddressBookPage( - onRecipientSelected: (recipient) => _startPayment( - context, - recipient: recipient, - ), + onRecipientSelected: (recipient) => + _startPayment(context, recipient: recipient), onAddRecipient: () => _openAddRecipient(context), onEditRecipient: (recipient) => _openEditRecipient(context, recipient: recipient), @@ -329,8 +326,8 @@ RouteBase payoutShellRoute() => ShellRoute( path: PayoutRoutes.reportPaymentPath, pageBuilder: (_, state) => NoTransitionPage( child: PaymentDetailsPage( - paymentId: state.uri.queryParameters[ - PayoutRoutes.reportPaymentIdQuery] ?? + paymentId: + state.uri.queryParameters[PayoutRoutes.reportPaymentIdQuery] ?? '', ), ), @@ -350,9 +347,7 @@ RouteBase payoutShellRoute() => ShellRoute( return NoTransitionPage( child: wallet != null - ? WalletEditPage( - onBack: () => _popOrGo(context), - ) + ? WalletEditPage(onBack: () => _popOrGo(context)) : Center(child: Text(loc.noWalletSelected)), ); }, @@ -361,11 +356,8 @@ RouteBase payoutShellRoute() => ShellRoute( name: PayoutRoutes.walletTopUp, path: PayoutRoutes.walletTopUpPath, pageBuilder: (context, state) { - return NoTransitionPage( - child: WalletTopUpPage( - onBack: () => _popOrGo(context), - ), + child: WalletTopUpPage(onBack: () => _popOrGo(context)), ); }, ), @@ -396,18 +388,12 @@ void _openEditRecipient(BuildContext context, {required Recipient recipient}) { context.pushNamed(PayoutRoutes.editRecipient); } -void _openWalletEdit( - BuildContext context, - Wallet wallet, -) { +void _openWalletEdit(BuildContext context, Wallet wallet) { context.read().selectWallet(wallet); context.pushToEditWallet(); } -void _openWalletTopUp( - BuildContext context, - Wallet wallet, -) { +void _openWalletTopUp(BuildContext context, Wallet wallet) { context.read().selectWallet(wallet); context.pushToWalletTopUp(); } diff --git a/frontend/pweb/lib/controllers/payments/amount_field.dart b/frontend/pweb/lib/controllers/payments/amount_field.dart index 1a818cbf..ccc946b3 100644 --- a/frontend/pweb/lib/controllers/payments/amount_field.dart +++ b/frontend/pweb/lib/controllers/payments/amount_field.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/utils/currency.dart'; @@ -15,7 +15,7 @@ class PaymentAmountFieldController extends ChangeNotifier { final FocusNode focusNode = FocusNode(); PaymentAmountProvider? _provider; - WalletsController? _wallets; + PaymentSourceController? _source; bool _isSyncingText = false; PaymentAmountMode _mode = PaymentAmountMode.debit; @@ -36,7 +36,7 @@ class PaymentAmountFieldController extends ChangeNotifier { PaymentAmountMode.settlement => _settlementCurrencyCode, }; - void update(PaymentAmountProvider provider, WalletsController wallets) { + void update(PaymentAmountProvider provider, PaymentSourceController source) { if (!identical(_provider, provider)) { _provider?.removeListener(_handleProviderChanged); _provider = provider; @@ -44,11 +44,11 @@ class PaymentAmountFieldController extends ChangeNotifier { _syncModeWithProvider(provider); } - if (!identical(_wallets, wallets)) { - _wallets?.removeListener(_handleWalletsChanged); - _wallets = wallets; - _wallets?.addListener(_handleWalletsChanged); - _normalizeModeForWallet(); + if (!identical(_source, source)) { + _source?.removeListener(_handleSourceChanged); + _source = source; + _source?.addListener(_handleSourceChanged); + _normalizeModeForSource(); } _syncTextWithAmount(provider.amount); @@ -79,16 +79,14 @@ class PaymentAmountFieldController extends ChangeNotifier { _syncTextWithAmount(provider.amount); final changed = _syncModeWithProvider(provider); if (changed) { - _normalizeModeForWallet(); + _normalizeModeForSource(); notifyListeners(); } } - void _handleWalletsChanged() { - final changed = _normalizeModeForWallet(); - if (changed) { - notifyListeners(); - } + void _handleSourceChanged() { + _normalizeModeForSource(); + notifyListeners(); } bool _syncModeWithProvider(PaymentAmountProvider provider) { @@ -98,7 +96,7 @@ class PaymentAmountFieldController extends ChangeNotifier { return true; } - bool _normalizeModeForWallet() { + bool _normalizeModeForSource() { if (isReverseModeAvailable || _mode != PaymentAmountMode.settlement) { return false; } @@ -108,9 +106,7 @@ class PaymentAmountFieldController extends ChangeNotifier { } String? get _sourceCurrencyCode { - final selectedWallet = _wallets?.selectedWallet; - if (selectedWallet == null) return null; - return currencyCodeToString(selectedWallet.currency); + return _source?.selectedCurrencyCode; } PaymentAmountMode _modeFromSettlementMode(SettlementMode mode) => @@ -150,7 +146,7 @@ class PaymentAmountFieldController extends ChangeNotifier { @override void dispose() { _provider?.removeListener(_handleProviderChanged); - _wallets?.removeListener(_handleWalletsChanged); + _source?.removeListener(_handleSourceChanged); focusNode.dispose(); textController.dispose(); super.dispose(); diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 07eea254..81a72e58 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -610,6 +610,7 @@ } } }, + "noFee": "No fee", "recipientWillReceive": "Recipient will receive: {amount}", "@recipientWillReceive": { diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index dbbf68e1..91a447dd 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -610,6 +610,7 @@ } } }, + "noFee": "Нет комиссии", "recipientWillReceive": "Получатель получит: {amount}", "@recipientWillReceive": { diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/controller.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/controller.dart index 3c97136a..df2f6484 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/controller.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/controller.dart @@ -1,21 +1,124 @@ import 'package:flutter/foundation.dart'; +import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/provider/ledger.dart'; -class CarouselIndexController with ChangeNotifier { +import 'package:pweb/pages/dashboard/buttons/balance/balance_item.dart'; + +class BalanceCarouselController with ChangeNotifier { + WalletsController? _walletsController; + List _items = const [BalanceItem.addAction()]; int _index = 0; + List get items => _items; int get index => _index; - void setIndex(int value, int max) { - final next = value.clamp(0, max > 0 ? max - 1 : 0); - if (next == _index) return; + void update({ + required WalletsController walletsController, + required LedgerAccountsProvider ledgerProvider, + }) { + _walletsController = walletsController; + + final nextItems = [ + ...walletsController.wallets.map(BalanceItem.wallet), + ...ledgerProvider.accounts.map(BalanceItem.ledger), + const BalanceItem.addAction(), + ]; + + final nextIndex = _resolveNextIndex(nextItems, walletsController); + final hasItemsChanged = !_isSameItems(_items, nextItems); + final hasIndexChanged = _index != nextIndex; + + _items = nextItems; + _index = nextIndex; + + if (hasItemsChanged || hasIndexChanged) { + notifyListeners(); + } + } + + void onPageChanged(int value) { + final next = _clampIndex(value, _items.length); + if (_index == next) { + _syncSelectedWallet(); + return; + } + _index = next; + _syncSelectedWallet(); notifyListeners(); } - void reset() { - if (_index == 0) return; - _index = 0; - notifyListeners(); + void goBack() => onPageChanged(_index - 1); + + void goForward() => onPageChanged(_index + 1); + + int _resolveNextIndex( + List nextItems, + WalletsController walletsController, + ) { + final currentWalletRef = _currentWalletRef(_items, _index); + if (currentWalletRef != null) { + final byCurrentWallet = _walletIndexByRef(nextItems, currentWalletRef); + if (byCurrentWallet != null) return byCurrentWallet; + + final selectedWalletRef = walletsController.selectedWalletRef; + final bySelectedWallet = _walletIndexByRef(nextItems, selectedWalletRef); + if (bySelectedWallet != null) return bySelectedWallet; + } + + return _clampIndex(_index, nextItems.length); + } + + String? _currentWalletRef(List items, int index) { + if (items.isEmpty || index < 0 || index >= items.length) return null; + final current = items[index]; + if (!current.isWallet) return null; + return current.wallet?.id; + } + + int? _walletIndexByRef(List items, String? walletRef) { + if (walletRef == null || walletRef.isEmpty) return null; + final idx = items.indexWhere( + (item) => item.isWallet && item.wallet?.id == walletRef, + ); + if (idx < 0) return null; + return idx; + } + + int _clampIndex(int value, int itemCount) { + if (itemCount <= 0) return 0; + return value.clamp(0, itemCount - 1); + } + + bool _isSameItems(List left, List right) { + if (left.length != right.length) return false; + + for (var i = 0; i < left.length; i++) { + final a = left[i]; + final b = right[i]; + if (a.type != b.type) return false; + if (_itemIdentity(a) != _itemIdentity(b)) return false; + } + + return true; + } + + String _itemIdentity(BalanceItem item) => switch (item.type) { + BalanceItemType.wallet => item.wallet?.id ?? '', + BalanceItemType.ledger => item.account?.ledgerAccountRef ?? '', + BalanceItemType.addAction => 'add', + }; + + void _syncSelectedWallet() { + final walletsController = _walletsController; + if (walletsController == null || _items.isEmpty) return; + + final current = _items[_index]; + if (!current.isWallet || current.wallet == null) return; + + final wallet = current.wallet!; + if (walletsController.selectedWallet?.id == wallet.id) return; + walletsController.selectWallet(wallet); } } diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/providers.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/providers.dart new file mode 100644 index 00000000..1de0dac2 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/providers.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/provider/ledger.dart'; + +import 'package:pweb/pages/dashboard/buttons/balance/controller.dart'; + +class BalanceWidgetProviders extends StatelessWidget { + final Widget child; + + const BalanceWidgetProviders({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProxyProvider2< + WalletsController, + LedgerAccountsProvider, + BalanceCarouselController + >( + create: (_) => BalanceCarouselController(), + update: (_, walletsController, ledgerProvider, controller) => controller! + ..update( + walletsController: walletsController, + ledgerProvider: ledgerProvider, + ), + child: child, + ); + } +} diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/widget.dart similarity index 59% rename from frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart rename to frontend/pweb/lib/pages/dashboard/buttons/balance/widget.dart index da14d823..a719d3a2 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/widget.dart @@ -8,11 +8,9 @@ import 'package:pshared/models/payment/wallet.dart'; import 'package:pweb/pages/dashboard/buttons/balance/carousel.dart'; import 'package:pweb/pages/dashboard/buttons/balance/controller.dart'; -import 'package:pweb/pages/dashboard/buttons/balance/balance_item.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - class BalanceWidget extends StatelessWidget { final ValueChanged onTopUp; final ValueChanged onWalletTap; @@ -27,12 +25,13 @@ class BalanceWidget extends StatelessWidget { Widget build(BuildContext context) { final walletsController = context.watch(); final ledgerProvider = context.watch(); - final carousel = context.watch(); + final carousel = context.watch(); final loc = AppLocalizations.of(context)!; final wallets = walletsController.wallets; final accounts = ledgerProvider.accounts; - final isLoading = walletsController.isLoading && + final isLoading = + walletsController.isLoading && ledgerProvider.isLoading && wallets.isEmpty && accounts.isEmpty; @@ -41,40 +40,10 @@ class BalanceWidget extends StatelessWidget { return const Center(child: CircularProgressIndicator()); } - final items = [ - ...wallets.map(BalanceItem.wallet), - ...accounts.map(BalanceItem.ledger), - const BalanceItem.addAction(), - ]; - - if (items.isEmpty) { - return const SizedBox.shrink(); - } - - // Ensure index is always valid when list changes - carousel.setIndex(carousel.index, items.length); - - final index = carousel.index; - final current = items[index]; - - // Single source of truth: controller - if (current.isWallet) { - final wallet = current.wallet!; - if (walletsController.selectedWallet?.id != wallet.id) { - walletsController.selectWallet(wallet); - } - } - final carouselWidget = BalanceCarousel( - items: items, - currentIndex: index, - onIndexChanged: (i) { - carousel.setIndex(i, items.length); - final next = items[carousel.index]; - if (next.isWallet) { - walletsController.selectWallet(next.wallet!); - } - }, + items: carousel.items, + currentIndex: carousel.index, + onIndexChanged: carousel.onPageChanged, onTopUp: onTopUp, onWalletTap: onWalletTap, ); diff --git a/frontend/pweb/lib/pages/dashboard/dashboard.dart b/frontend/pweb/lib/pages/dashboard/dashboard.dart index b5ed840c..bc709b6d 100644 --- a/frontend/pweb/lib/pages/dashboard/dashboard.dart +++ b/frontend/pweb/lib/pages/dashboard/dashboard.dart @@ -1,14 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/payment/wallet.dart'; import 'package:pweb/models/dashboard/dashboard_payment_mode.dart'; -import 'package:pweb/pages/dashboard/buttons/balance/balance.dart'; -import 'package:pweb/pages/dashboard/buttons/balance/controller.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/widget.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/providers.dart'; import 'package:pweb/pages/dashboard/buttons/buttons.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/widgets/title.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/widget.dart'; @@ -17,7 +15,6 @@ import 'package:pweb/pages/loader.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - class AppSpacing { static const double small = 10; static const double medium = 16; @@ -86,8 +83,7 @@ class _DashboardPageState extends State { ], ), const SizedBox(height: AppSpacing.medium), - ChangeNotifierProvider( - create: (_) => CarouselIndexController(), + BalanceWidgetProviders( child: BalanceWidget( onTopUp: widget.onTopUp, onWalletTap: widget.onWalletTap, diff --git a/frontend/pweb/lib/pages/dashboard/payouts/amount/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/amount/widget.dart index c2e72503..4ddaa3e7 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/amount/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/amount/widget.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/provider/payment/amount.dart'; import 'package:pweb/controllers/payments/amount_field.dart'; @@ -15,15 +15,15 @@ class PaymentAmountWidget extends StatelessWidget { Widget build(BuildContext context) { return ChangeNotifierProxyProvider2< PaymentAmountProvider, - WalletsController, + PaymentSourceController, PaymentAmountFieldController >( create: (ctx) { final initialAmount = ctx.read().amount; return PaymentAmountFieldController(initialAmount: initialAmount); }, - update: (ctx, amountProvider, wallets, controller) { - controller!.update(amountProvider, wallets); + update: (ctx, amountProvider, source, controller) { + controller!.update(amountProvider, source); return controller; }, child: const PaymentAmountField(), diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/form/details.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/form/details.dart deleted file mode 100644 index b4ee53a5..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/single/form/details.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:pshared/models/payment/methods/data.dart'; -import 'package:pshared/models/payment/type.dart'; - -import 'package:pweb/pages/payment_methods/form.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -class PaymentDetailsSection extends StatelessWidget { - final bool isFormVisible; - final bool isEditable; - final VoidCallback? onToggle; - final PaymentType? selectedType; - final PaymentMethodData? data; - - const PaymentDetailsSection({ - super.key, - required this.isFormVisible, - this.onToggle, - required this.selectedType, - required this.data, - required this.isEditable, - }); - - static const double toggleSpacing = 8.0; - static const double formVisibleSpacing = 30.0; - static const double formHiddenSpacing = 20.0; - static const Duration animationDuration = Duration(milliseconds: 200); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final loc = AppLocalizations.of(context)!; - - final toggleIcon = isFormVisible ? Icons.expand_less : Icons.expand_more; - final toggleText = isFormVisible ? loc.hideDetails : loc.showDetails; - - return Column( - children: [ - if (!isEditable && onToggle != null) - TextButton.icon( - onPressed: onToggle, - icon: Icon(toggleIcon, color: theme.colorScheme.primary), - label: Text( - toggleText, - style: TextStyle(color: theme.colorScheme.primary), - ), - ), - const SizedBox(height: toggleSpacing), - AnimatedCrossFade( - duration: animationDuration, - crossFadeState: isFormVisible ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: PaymentMethodForm( - key: const ValueKey('formVisible'), - isEditable: isEditable, - selectedType: selectedType, - onChanged: (_) {}, - initialData: data, - ), - secondChild: const SizedBox.shrink(key: ValueKey('formHidden')), - ), - SizedBox(height: isFormVisible ? formVisibleSpacing : formHiddenSpacing), - ], - ); - } -} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/form/header.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/form/header.dart deleted file mode 100644 index a83223a1..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/single/form/header.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:pshared/models/recipient/recipient.dart'; - -import 'package:pweb/pages/dashboard/payouts/single/address_book/avatar.dart'; - - -class RecipientHeader extends StatelessWidget{ - final Recipient recipient; - - const RecipientHeader({super.key, required this.recipient}); - - final double _avatarRadius = 20; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return ListTile( - contentPadding: EdgeInsets.zero, - leading: RecipientAvatar( - isVisible: false, - name: recipient.name, - avatarUrl: recipient.avatarUrl, - avatarRadius: _avatarRadius, - nameStyle: Theme.of(context).textTheme.bodyMedium, - ), - title: Text(recipient.name, style: theme.textTheme.titleLarge), - subtitle: Text(recipient.email, style: theme.textTheme.bodyLarge), - ); - } -} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/payouts/summary/fee.dart b/frontend/pweb/lib/pages/dashboard/payouts/summary/fee.dart index 6c1a005f..55e0a11c 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/summary/fee.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/summary/fee.dart @@ -14,10 +14,15 @@ class PaymentFeeRow extends StatelessWidget { @override Widget build(BuildContext context) => Consumer( - builder: (context, provider, _) => PaymentSummaryRow( - labelFactory: AppLocalizations.of(context)!.fee, - asset: provider.fee, - style: Theme.of(context).textTheme.titleMedium, - ), + builder: (context, provider, _) { + final fee = provider.fee; + final l10 = AppLocalizations.of(context)!; + return PaymentSummaryRow( + labelFactory: l10.fee, + asset: fee, + value: fee == null ? l10.noFee : null, + style: Theme.of(context).textTheme.titleMedium, + ); + }, ); } diff --git a/frontend/pweb/lib/pages/payout_page/send/body.dart b/frontend/pweb/lib/pages/payout_page/send/body.dart index 25e9a100..f50c6cb0 100644 --- a/frontend/pweb/lib/pages/payout_page/send/body.dart +++ b/frontend/pweb/lib/pages/payout_page/send/body.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/recipient/recipient.dart'; -import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/provider.dart'; @@ -13,7 +12,6 @@ import 'package:pweb/models/state/visibility.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - class PaymentPageBody extends StatelessWidget { final ValueChanged? onBack; final Recipient? recipient; @@ -23,7 +21,6 @@ class PaymentPageBody extends StatelessWidget { final PaymentMethodsProvider methodsProvider; final ControlState sendState; final int cooldownRemainingSeconds; - final ValueChanged onWalletSelected; final PayoutDestination fallbackDestination; final TextEditingController searchController; final FocusNode searchFocusNode; @@ -46,7 +43,6 @@ class PaymentPageBody extends StatelessWidget { required this.methodsProvider, required this.sendState, required this.cooldownRemainingSeconds, - required this.onWalletSelected, required this.fallbackDestination, required this.searchController, required this.searchFocusNode, @@ -70,7 +66,9 @@ class PaymentPageBody extends StatelessWidget { if (methodsProvider.error != null) { return PaymentMethodsErrorView( - message: loc.notificationError(methodsProvider.error ?? loc.noErrorInformation), + message: loc.notificationError( + methodsProvider.error ?? loc.noErrorInformation, + ), ); } @@ -80,7 +78,6 @@ class PaymentPageBody extends StatelessWidget { recipientProvider: recipientProvider, searchQuery: searchQuery, filteredRecipients: filteredRecipients, - onWalletSelected: onWalletSelected, fallbackDestination: fallbackDestination, sendState: sendState, cooldownRemainingSeconds: cooldownRemainingSeconds, diff --git a/frontend/pweb/lib/pages/payout_page/send/content.dart b/frontend/pweb/lib/pages/payout_page/send/content.dart index 0a45f067..021d313d 100644 --- a/frontend/pweb/lib/pages/payout_page/send/content.dart +++ b/frontend/pweb/lib/pages/payout_page/send/content.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/provider/recipient/provider.dart'; @@ -13,14 +12,12 @@ import 'package:pweb/models/state/visibility.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - class PaymentPageContent extends StatelessWidget { final ValueChanged? onBack; final Recipient? recipient; final RecipientsProvider recipientProvider; final String searchQuery; final List filteredRecipients; - final ValueChanged onWalletSelected; final PayoutDestination fallbackDestination; final ControlState sendState; final int cooldownRemainingSeconds; @@ -42,7 +39,6 @@ class PaymentPageContent extends StatelessWidget { required this.recipientProvider, required this.searchQuery, required this.filteredRecipients, - required this.onWalletSelected, required this.fallbackDestination, required this.sendState, required this.cooldownRemainingSeconds, @@ -79,7 +75,6 @@ class PaymentPageContent extends StatelessWidget { recipientProvider: recipientProvider, searchQuery: searchQuery, filteredRecipients: filteredRecipients, - onWalletSelected: onWalletSelected, fallbackDestination: fallbackDestination, sendState: sendState, cooldownRemainingSeconds: cooldownRemainingSeconds, diff --git a/frontend/pweb/lib/pages/payout_page/send/content/sections.dart b/frontend/pweb/lib/pages/payout_page/send/content/sections.dart index ff3166cd..a16fc460 100644 --- a/frontend/pweb/lib/pages/payout_page/send/content/sections.dart +++ b/frontend/pweb/lib/pages/payout_page/send/content/sections.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/provider/recipient/provider.dart'; @@ -13,7 +12,6 @@ import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/models/state/control_state.dart'; import 'package:pweb/models/state/visibility.dart'; - class PaymentPageContentSections extends StatelessWidget { final AppDimensions dimensions; final String sourceOfFundsTitle; @@ -22,7 +20,6 @@ class PaymentPageContentSections extends StatelessWidget { final RecipientsProvider recipientProvider; final String searchQuery; final List filteredRecipients; - final ValueChanged onWalletSelected; final PayoutDestination fallbackDestination; final ControlState sendState; final int cooldownRemainingSeconds; @@ -46,7 +43,6 @@ class PaymentPageContentSections extends StatelessWidget { required this.recipientProvider, required this.searchQuery, required this.filteredRecipients, - required this.onWalletSelected, required this.fallbackDestination, required this.sendState, required this.cooldownRemainingSeconds, @@ -77,7 +73,6 @@ class PaymentPageContentSections extends StatelessWidget { PaymentPageSourceSection( dimensions: dimensions, title: sourceOfFundsTitle, - onWalletSelected: onWalletSelected, ), SizedBox(height: dimensions.paddingXLarge), PaymentPageRecipientSection( diff --git a/frontend/pweb/lib/pages/payout_page/send/content/source_section.dart b/frontend/pweb/lib/pages/payout_page/send/content/source_section.dart index b86b63a1..54b0f298 100644 --- a/frontend/pweb/lib/pages/payout_page/send/content/source_section.dart +++ b/frontend/pweb/lib/pages/payout_page/send/content/source_section.dart @@ -1,29 +1,20 @@ import 'package:flutter/material.dart'; -import 'package:pshared/models/payment/wallet.dart'; - import 'package:pweb/pages/payout_page/send/widgets/source_of_funds_card.dart'; import 'package:pweb/utils/dimensions.dart'; - class PaymentPageSourceSection extends StatelessWidget { final AppDimensions dimensions; final String title; - final ValueChanged onWalletSelected; const PaymentPageSourceSection({ super.key, required this.dimensions, required this.title, - required this.onWalletSelected, }); @override Widget build(BuildContext context) { - return PaymentSourceOfFundsCard( - dimensions: dimensions, - title: title, - onWalletSelected: onWalletSelected, - ); + return PaymentSourceOfFundsCard(dimensions: dimensions, title: title); } } diff --git a/frontend/pweb/lib/pages/payout_page/send/page_view.dart b/frontend/pweb/lib/pages/payout_page/send/page_view.dart index 7191c794..442719c9 100644 --- a/frontend/pweb/lib/pages/payout_page/send/page_view.dart +++ b/frontend/pweb/lib/pages/payout_page/send/page_view.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/provider/payment/quotation/quotation.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; @@ -15,7 +14,6 @@ import 'package:pweb/controllers/payments/page_ui.dart'; import 'package:pweb/controllers/payouts/payout_verification.dart'; import 'package:pweb/models/state/control_state.dart'; - class PaymentPageView extends StatelessWidget { final PaymentPageUiController uiController; final ValueChanged? onBack; @@ -50,8 +48,8 @@ class PaymentPageView extends StatelessWidget { final methodsProvider = context.watch(); final recipientProvider = context.watch(); final quotationProvider = context.watch(); - final verificationController = - context.watch(); + final verificationController = context + .watch(); final verificationContextKey = quotationProvider.quotation?.quoteRef ?? quotationProvider.quotation?.idempotencyKey; @@ -62,10 +60,10 @@ class PaymentPageView extends StatelessWidget { ); final sendState = verificationController.isCooldownActiveFor(verificationContextKey) - ? ControlState.disabled - : (recipient == null - ? ControlState.disabled - : ControlState.enabled); + ? ControlState.disabled + : (recipient == null + ? ControlState.disabled + : ControlState.enabled); return PaymentPageBody( onBack: onBack, @@ -76,10 +74,8 @@ class PaymentPageView extends StatelessWidget { filteredRecipients: filteredRecipients, methodsProvider: methodsProvider, sendState: sendState, - cooldownRemainingSeconds: - verificationController - .cooldownRemainingSecondsFor(verificationContextKey), - onWalletSelected: context.read().selectWallet, + cooldownRemainingSeconds: verificationController + .cooldownRemainingSecondsFor(verificationContextKey), searchController: uiController.searchController, searchFocusNode: uiController.searchFocusNode, onSearchChanged: onSearchChanged, diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/method_selector.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/method_selector.dart index 829182a4..25bd0dc4 100644 --- a/frontend/pweb/lib/pages/payout_page/send/widgets/method_selector.dart +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/method_selector.dart @@ -2,25 +2,16 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; -import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/controllers/payment/source.dart'; import 'package:pweb/widgets/payment/source_wallet_selector.dart'; - class PaymentMethodSelector extends StatelessWidget { - final ValueChanged onMethodChanged; - - const PaymentMethodSelector({ - super.key, - required this.onMethodChanged, - }); + const PaymentMethodSelector({super.key}); @override - Widget build(BuildContext context) => Consumer( - builder: (context, provider, _) => SourceWalletSelector( - walletsController: provider, - onChanged: onMethodChanged, - ), - ); + Widget build(BuildContext context) => Consumer( + builder: (context, provider, _) => + SourceWalletSelector(sourceController: provider), + ); } diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart index 8eeaf440..3cc76c8c 100644 --- a/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart @@ -2,26 +2,23 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; -import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/controllers/payment/source.dart'; import 'package:pweb/pages/payout_page/send/widgets/method_selector.dart'; import 'package:pweb/pages/payout_page/send/widgets/section/title.dart'; import 'package:pweb/pages/payout_page/send/widgets/section/card.dart'; import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/widgets/refresh_balance/ledger.dart'; import 'package:pweb/widgets/refresh_balance/wallet.dart'; - class PaymentSourceOfFundsCard extends StatelessWidget { final AppDimensions dimensions; final String title; - final ValueChanged onWalletSelected; const PaymentSourceOfFundsCard({ super.key, required this.dimensions, required this.title, - required this.onWalletSelected, }); @override @@ -33,21 +30,29 @@ class PaymentSourceOfFundsCard extends StatelessWidget { Row( children: [ Expanded(child: SectionTitle(title)), - Consumer( + Consumer( builder: (context, provider, _) { - final selectedWalletId = provider.selectedWallet?.id; - if (selectedWalletId == null) { - return const SizedBox.shrink(); + final selectedWallet = provider.selectedWallet; + if (selectedWallet != null) { + return WalletBalanceRefreshButton( + walletRef: selectedWallet.id, + ); } - return WalletBalanceRefreshButton(walletRef: selectedWalletId); + + final selectedLedger = provider.selectedLedgerAccount; + if (selectedLedger != null) { + return LedgerBalanceRefreshButton( + ledgerAccountRef: selectedLedger.ledgerAccountRef, + ); + } + + return const SizedBox.shrink(); }, ), ], ), SizedBox(height: dimensions.paddingSmall), - PaymentMethodSelector( - onMethodChanged: onWalletSelected, - ), + const PaymentMethodSelector(), ], ), ); diff --git a/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart b/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart index a78eac92..d7ca0714 100644 --- a/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart +++ b/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart @@ -1,42 +1,137 @@ import 'package:flutter/material.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/controllers/payment/source.dart'; +import 'package:pshared/models/ledger/account.dart'; +import 'package:pshared/models/payment/source_type.dart'; import 'package:pshared/models/payment/wallet.dart'; import 'package:pshared/utils/currency.dart'; +import 'package:pshared/utils/money.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; +typedef _SourceOptionKey = ({PaymentSourceType type, String ref}); + class SourceWalletSelector extends StatelessWidget { const SourceWalletSelector({ super.key, - required this.walletsController, + this.walletsController, + this.sourceController, this.isBusy = false, this.onChanged, - }); + }) : assert( + (walletsController != null) != (sourceController != null), + 'Provide either walletsController or sourceController', + ); - final WalletsController walletsController; + final WalletsController? walletsController; + final PaymentSourceController? sourceController; final bool isBusy; final ValueChanged? onChanged; @override Widget build(BuildContext context) { - final wallets = walletsController.wallets; - final selectedWalletRef = walletsController.selectedWalletRef; + final source = sourceController; + if (source != null) { + final selectedWallet = source.selectedWallet; + final selectedLedger = source.selectedLedgerAccount; + final selectedValue = switch (source.selectedType) { + PaymentSourceType.wallet => + selectedWallet == null ? null : _walletKey(selectedWallet.id), + PaymentSourceType.ledger => + selectedLedger == null + ? null + : _ledgerKey(selectedLedger.ledgerAccountRef), + null => null, + }; + + return _buildSourceSelector( + context: context, + wallets: source.wallets, + ledgerAccounts: source.ledgerAccounts, + selectedValue: selectedValue, + onChanged: (value) { + if (value.type == PaymentSourceType.wallet) { + source.selectWalletByRef(value.ref); + final selected = source.selectedWallet; + if (selected != null) { + onChanged?.call(selected); + } + return; + } + + if (value.type == PaymentSourceType.ledger) { + source.selectLedgerByRef(value.ref); + } + }, + ); + } + + final wallets = walletsController!; + return _buildSourceSelector( + context: context, + wallets: wallets.wallets, + ledgerAccounts: const [], + selectedValue: wallets.selectedWalletRef == null + ? null + : _walletKey(wallets.selectedWalletRef!), + onChanged: (value) { + if (value.type != PaymentSourceType.wallet) return; + wallets.selectWalletByRef(value.ref); + final selected = wallets.selectedWallet; + if (selected != null) { + onChanged?.call(selected); + } + }, + ); + } + + Widget _buildSourceSelector({ + required BuildContext context, + required List wallets, + required List ledgerAccounts, + required _SourceOptionKey? selectedValue, + required ValueChanged<_SourceOptionKey> onChanged, + }) { final theme = Theme.of(context); final l10n = AppLocalizations.of(context)!; - if (wallets.isEmpty) { + if (wallets.isEmpty && ledgerAccounts.isEmpty) { return Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall); } - final effectiveSelectedWalletRef = selectedWalletRef != null && - wallets.any((wallet) => wallet.id == selectedWalletRef) - ? selectedWalletRef + final items = >[ + ...wallets.map((wallet) { + return DropdownMenuItem<_SourceOptionKey>( + value: _walletKey(wallet.id), + child: Text( + '${wallet.name} - ${_walletBalance(wallet)}', + overflow: TextOverflow.ellipsis, + ), + ); + }), + ...ledgerAccounts.map((ledger) { + return DropdownMenuItem<_SourceOptionKey>( + value: _ledgerKey(ledger.ledgerAccountRef), + child: Text( + '${ledger.name} - ${_ledgerBalance(ledger)}', + overflow: TextOverflow.ellipsis, + ), + ); + }), + ]; + + final knownValues = items + .map((item) => item.value) + .whereType<_SourceOptionKey>() + .toSet(); + final effectiveValue = knownValues.contains(selectedValue) + ? selectedValue : null; - return DropdownButtonFormField( - initialValue: effectiveSelectedWalletRef, + return DropdownButtonFormField<_SourceOptionKey>( + initialValue: effectiveValue, isExpanded: true, decoration: InputDecoration( labelText: l10n.whereGetMoney, @@ -46,48 +141,45 @@ class SourceWalletSelector extends StatelessWidget { vertical: 10, ), ), - items: wallets - .map( - (wallet) => DropdownMenuItem( - value: wallet.id, - child: Text( - '${_walletLabel(wallet)} - ${currencyCodeToSymbol(wallet.currency)} ${amountToString(wallet.balance)}', - overflow: TextOverflow.ellipsis, - ), - ), - ) - .toList(growable: false), + items: items, onChanged: isBusy ? null : (value) { if (value == null) return; - walletsController.selectWalletByRef(value); - final selected = walletsController.selectedWallet; - if (selected != null) { - onChanged?.call(selected); - } + onChanged(value); }, ); } - String _walletLabel(Wallet wallet) { - final description = wallet.description?.trim(); - if (description != null && description.isNotEmpty) { - return description; - } - final name = wallet.name.trim(); - if (name.isNotEmpty && !_looksLikeId(name)) { - return name; - } - final token = wallet.tokenSymbol?.trim(); - if (token != null && token.isNotEmpty) { - return '$token wallet'; - } - return '${currencyCodeToString(wallet.currency)} wallet'; + _SourceOptionKey _walletKey(String walletRef) => + (type: PaymentSourceType.wallet, ref: walletRef); + + _SourceOptionKey _ledgerKey(String ledgerAccountRef) => + (type: PaymentSourceType.ledger, ref: ledgerAccountRef); + + String _walletBalance(Wallet wallet) { + final symbol = currencyCodeToSymbol(wallet.currency); + return '$symbol ${amountToString(wallet.balance)}'; } - bool _looksLikeId(String value) { - return RegExp(r'^[a-f0-9]{12,}$', caseSensitive: false) - .hasMatch(value); + String _ledgerBalance(LedgerAccount account) { + final money = account.balance?.balance; + final rawAmount = money?.amount.trim(); + final amount = parseMoneyAmount(rawAmount, fallback: double.nan); + final amountText = amount.isNaN + ? (rawAmount == null || rawAmount.isEmpty ? '--' : rawAmount) + : amountToString(amount); + + final currencyCode = (money?.currency ?? account.currency) + .trim() + .toUpperCase(); + final symbol = currencySymbolFromCode(currencyCode); + if (symbol != null && symbol.trim().isNotEmpty) { + return '$symbol $amountText'; + } + if (currencyCode.isNotEmpty) { + return '$amountText $currencyCode'; + } + return amountText; } }