From f44ef56ff39dbe18cfebe8c258ebfa81f3b413e1 Mon Sep 17 00:00:00 2001 From: Arseni Date: Wed, 4 Feb 2026 02:01:22 +0300 Subject: [PATCH] WIP: integration with ledger --- .../lib/controllers/payment/source.dart | 67 +++++++++ .../pshared/lib/models/payment/source.dart | 31 ++++ .../payment/quotation/intent_builder.dart | 105 ++++++++++---- .../provider/payment/quotation/quotation.dart | 44 +++--- .../pshared/lib/provider/payment/source.dart | 33 +++++ .../pweb/lib/app/router/payout_shell.dart | 127 ++++++++++++----- frontend/pweb/lib/main.dart | 93 ++++++++---- .../dashboard/buttons/balance/balance.dart | 109 +++++++++++--- .../lib/pages/dashboard/payouts/amount.dart | 24 +++- .../dashboard/payouts/summary/widget.dart | 51 +++++-- .../invitations/widgets/form/fields.dart | 8 +- .../pweb/lib/pages/payment_methods/page.dart | 8 +- .../payment_methods/payment_page/body.dart | 13 +- .../payment_methods/payment_page/content.dart | 134 ------------------ .../payment_page/method_selector.dart | 76 ++++++++-- .../payment_methods/payment_page/page.dart | 13 +- frontend/pweb/lib/pages/payout_page/page.dart | 19 ++- .../wallet/edit/buttons/buttons.dart | 33 ++--- .../payout_page/wallet/edit/buttons/send.dart | 27 ++-- .../wallet/edit/buttons/top_up.dart | 19 ++- .../wallet/edit/ledger/balance_row.dart | 65 +++++++++ .../wallet/edit/ledger/copyable_row.dart | 40 ++++++ .../payout_page/wallet/edit/ledger/view.dart | 83 +++++++++++ .../payout_page/wallet/edit/ledger_page.dart | 42 ++++++ .../pages/payout_page/wallet/ledger/card.dart | 46 ++++++ .../payout_page/wallet/ledger/card_body.dart | 90 ++++++++++++ .../payout_page/wallet/ledger/format.dart | 27 ++++ .../lib/pages/payout_page/wallet/wigets.dart | 34 +++-- .../pweb/lib/pages/wallet_top_up/header.dart | 15 +- .../pages/wallet_top_up/ledger_content.dart | 72 ++++++++++ .../pweb/lib/pages/wallet_top_up/meta.dart | 14 +- .../pweb/lib/pages/wallet_top_up/page.dart | 69 ++++++--- 32 files changed, 1226 insertions(+), 405 deletions(-) create mode 100644 frontend/pshared/lib/controllers/payment/source.dart create mode 100644 frontend/pshared/lib/models/payment/source.dart create mode 100644 frontend/pshared/lib/provider/payment/source.dart delete mode 100644 frontend/pweb/lib/pages/payment_methods/payment_page/content.dart create mode 100644 frontend/pweb/lib/pages/payout_page/wallet/edit/ledger/balance_row.dart create mode 100644 frontend/pweb/lib/pages/payout_page/wallet/edit/ledger/copyable_row.dart create mode 100644 frontend/pweb/lib/pages/payout_page/wallet/edit/ledger/view.dart create mode 100644 frontend/pweb/lib/pages/payout_page/wallet/edit/ledger_page.dart create mode 100644 frontend/pweb/lib/pages/payout_page/wallet/ledger/card.dart create mode 100644 frontend/pweb/lib/pages/payout_page/wallet/ledger/card_body.dart create mode 100644 frontend/pweb/lib/pages/payout_page/wallet/ledger/format.dart create mode 100644 frontend/pweb/lib/pages/wallet_top_up/ledger_content.dart diff --git a/frontend/pshared/lib/controllers/payment/source.dart b/frontend/pshared/lib/controllers/payment/source.dart new file mode 100644 index 00000000..59d65710 --- /dev/null +++ b/frontend/pshared/lib/controllers/payment/source.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; + +import 'package:collection/collection.dart'; + +import 'package:pshared/models/payment/source.dart'; +import 'package:pshared/provider/payment/source.dart'; + + +class PaymentSourceController extends ChangeNotifier { + PaymentSourceProvider? _provider; + String? _selectedSourceKey; + + List get sources => _provider?.sources ?? const []; + + PaymentSource? get selectedSource { + final key = _selectedSourceKey; + if (key == null) return null; + return sources.firstWhereOrNull((source) => source.key == key); + } + + void update(PaymentSourceProvider provider) { + _provider = provider; + final nextSources = provider.sources; + final nextSelectedKey = _resolveSelectedKey( + currentKey: _selectedSourceKey, + sources: nextSources, + ); + + if (nextSelectedKey == _selectedSourceKey) return; + _selectedSourceKey = nextSelectedKey; + notifyListeners(); + } + + void selectSource(PaymentSource source) { + if (_selectedSourceKey == source.key) return; + _selectedSourceKey = source.key; + notifyListeners(); + } + + void selectWalletByRef(String walletRef) { + final source = sources.firstWhereOrNull( + (s) => s.type == PaymentSourceType.wallet && s.id == walletRef, + ); + if (source == null) return; + selectSource(source); + } + + void selectLedgerByRef(String ledgerAccountRef) { + final source = sources.firstWhereOrNull( + (s) => s.type == PaymentSourceType.ledger && s.id == ledgerAccountRef, + ); + if (source == null) return; + selectSource(source); + } + + String? _resolveSelectedKey({ + required String? currentKey, + required List sources, + }) { + if (sources.isEmpty) return null; + if (currentKey != null && + sources.any((source) => source.key == currentKey)) { + return currentKey; + } + return sources.first.key; + } +} diff --git a/frontend/pshared/lib/models/payment/source.dart b/frontend/pshared/lib/models/payment/source.dart new file mode 100644 index 00000000..400efa95 --- /dev/null +++ b/frontend/pshared/lib/models/payment/source.dart @@ -0,0 +1,31 @@ +import 'package:pshared/models/ledger/account.dart'; +import 'package:pshared/models/payment/wallet.dart'; + + +enum PaymentSourceType { wallet, ledger } + +class PaymentSource { + final PaymentSourceType type; + final Wallet? wallet; + final LedgerAccount? ledgerAccount; + + const PaymentSource._({required this.type, this.wallet, this.ledgerAccount}); + + const PaymentSource.wallet(Wallet wallet) + : this._(type: PaymentSourceType.wallet, wallet: wallet); + + const PaymentSource.ledger(LedgerAccount account) + : this._(type: PaymentSourceType.ledger, ledgerAccount: account); + + String get id => switch (type) { + PaymentSourceType.wallet => wallet!.id, + PaymentSourceType.ledger => ledgerAccount!.ledgerAccountRef, + }; + + String get key => '${type.name}:$id'; + + String get name => switch (type) { + PaymentSourceType.wallet => wallet!.name, + PaymentSourceType.ledger => ledgerAccount!.name, + }; +} diff --git a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart index b205bb03..4909f867 100644 --- a/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart +++ b/frontend/pshared/lib/provider/payment/quotation/intent_builder.dart @@ -1,4 +1,3 @@ -import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/models/payment/asset.dart'; import 'package:pshared/models/payment/chain_network.dart'; import 'package:pshared/models/payment/currency_pair.dart'; @@ -9,66 +8,109 @@ import 'package:pshared/models/payment/kind.dart'; import 'package:pshared/models/payment/methods/card.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/source.dart'; import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/money.dart'; import 'package:pshared/models/payment/settlement_mode.dart'; import 'package:pshared/models/payment/intent.dart'; import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/controllers/payment/source.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'; - class QuotationIntentBuilder { PaymentIntent? build({ required PaymentAmountProvider payment, - required WalletsController wallets, + required PaymentSourceController sources, required PaymentFlowProvider flow, required RecipientsProvider recipients, }) { - final selectedWallet = wallets.selectedWallet; + final selectedSource = sources.selectedSource; final paymentData = flow.selectedPaymentData; final selectedMethod = flow.selectedMethod; - if (selectedWallet == null || paymentData == null) return null; + if (selectedSource == null || paymentData == null) return null; final customer = _buildCustomer( recipient: recipients.currentObject, method: selectedMethod, data: paymentData, ); + final sourceCurrency = _resolveSourceCurrency(selectedSource); + if (sourceCurrency == null) return null; + final targetCurrency = _resolveTargetCurrency(paymentData); + final fxIntent = _buildFxIntent( + baseCurrency: sourceCurrency, + quoteCurrency: targetCurrency, + ); final amount = Money( amount: payment.amount.toString(), - // TODO: adapt to possible other sources - currency: currencyCodeToString(selectedWallet.currency), - ); - final fxIntent = FxIntent( - pair: CurrencyPair( - base: currencyCodeToString(selectedWallet.currency), - quote: 'RUB', // TODO: exentd target currencies - ), - side: FxSide.sellBaseBuyQuote, + currency: sourceCurrency, ); 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: _buildSourceEndpoint(selectedSource), fx: fxIntent, - settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource, - settlementCurrency: _resolveSettlementCurrency(amount: amount, fx: fxIntent), + settlementMode: payment.payerCoversFee + ? SettlementMode.fixReceived + : SettlementMode.fixSource, + settlementCurrency: _resolveSettlementCurrency( + amount: amount, + fx: fxIntent, + ), customer: customer, ); } + String _resolveTargetCurrency(PaymentMethodData destination) { + // Current payout flow is RUB-settlement oriented. + // Avoid requesting unsupported self-pairs (e.g. RUB/RUB). + return 'RUB'; + } + + FxIntent? _buildFxIntent({ + required String baseCurrency, + required String quoteCurrency, + }) { + final base = baseCurrency.trim().toUpperCase(); + final quote = quoteCurrency.trim().toUpperCase(); + if (base.isEmpty || quote.isEmpty || base == quote) { + return null; + } + return FxIntent( + pair: CurrencyPair(base: base, quote: quote), + side: FxSide.sellBaseBuyQuote, + ); + } + + String? _resolveSourceCurrency(PaymentSource source) { + return switch (source.type) { + PaymentSourceType.wallet => currencyCodeToString(source.wallet!.currency), + PaymentSourceType.ledger => source.ledgerAccount?.currency, + }; + } + + PaymentMethodData _buildSourceEndpoint(PaymentSource source) { + return switch (source.type) { + PaymentSourceType.wallet => ManagedWalletPaymentMethod( + managedWalletRef: source.wallet!.id, + asset: PaymentAsset( + tokenSymbol: source.wallet?.tokenSymbol ?? '', + chain: source.wallet?.network ?? ChainNetwork.unspecified, + ), + ), + PaymentSourceType.ledger => LedgerPaymentMethod( + ledgerAccountRef: source.ledgerAccount!.ledgerAccountRef, + ), + }; + } + String _resolveSettlementCurrency({ required Money amount, required FxIntent? fx, @@ -85,8 +127,12 @@ class QuotationIntentBuilder { case FxSide.unspecified: break; } - if (amount.currency == pair.base && pair.quote.isNotEmpty) return pair.quote; - if (amount.currency == pair.quote && pair.base.isNotEmpty) return pair.base; + if (amount.currency == pair.base && pair.quote.isNotEmpty) { + return pair.quote; + } + if (amount.currency == pair.quote && pair.base.isNotEmpty) { + return pair.base; + } if (pair.quote.isNotEmpty) return pair.quote; if (pair.base.isNotEmpty) return pair.base; } @@ -111,8 +157,9 @@ class QuotationIntentBuilder { : name.trim().split(RegExp(r'\s+')); final firstName = parts.isNotEmpty ? parts.first : null; final lastName = parts.length >= 2 ? parts.last : null; - final middleName = - parts.length > 2 ? parts.sublist(1, parts.length - 1).join(' ') : null; + final middleName = parts.length > 2 + ? parts.sublist(1, parts.length - 1).join(' ') + : null; return Customer( id: id, @@ -139,7 +186,9 @@ class QuotationIntentBuilder { return iban.accountHolder.trim(); } - final bank = method?.bankAccountData ?? (data is RussianBankAccountPaymentMethod ? data : null); + final bank = + method?.bankAccountData ?? + (data is RussianBankAccountPaymentMethod ? data : null); if (bank != null && bank.recipientName.trim().isNotEmpty) { return bank.recipientName.trim(); } diff --git a/frontend/pshared/lib/provider/payment/quotation/quotation.dart b/frontend/pshared/lib/provider/payment/quotation/quotation.dart index f5f45c7d..d75d2f5e 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'; @@ -23,19 +23,22 @@ import 'package:pshared/provider/payment/quotation/intent_builder.dart'; import 'package:pshared/service/payment/quotation.dart'; import 'package:pshared/utils/exception.dart'; - class QuotationProvider extends ChangeNotifier { static final _logger = Logger('provider.payment.quotation'); - Resource _quotation = Resource(data: null, isLoading: false, error: null); + Resource _quotation = Resource( + data: null, + isLoading: false, + error: null, + ); late OrganizationsProvider _organizations; bool _isLoaded = false; PaymentIntent? _lastIntent; final QuotationIntentBuilder _intentBuilder = QuotationIntentBuilder(); void update( - OrganizationsProvider venue, + OrganizationsProvider venue, PaymentAmountProvider payment, - WalletsController wallets, + PaymentSourceController sources, PaymentFlowProvider flow, RecipientsProvider recipients, PaymentMethodsProvider _, @@ -43,7 +46,7 @@ class QuotationProvider extends ChangeNotifier { _organizations = venue; final intent = _intentBuilder.build( payment: payment, - wallets: wallets, + sources: sources, flow: flow, recipients: recipients, ); @@ -58,7 +61,8 @@ class QuotationProvider extends ChangeNotifier { bool get isLoading => _quotation.isLoading; Exception? get error => _quotation.error; bool get canRefresh => _lastIntent != null; - bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null; + bool get isReady => + _isLoaded && !_quotation.isLoading && _quotation.error == null; DateTime? get quoteExpiresAt { final expiresAtUnixMs = quotation?.fxQuote?.expiresAtUnixMs; @@ -66,10 +70,10 @@ class QuotationProvider extends ChangeNotifier { return DateTime.fromMillisecondsSinceEpoch(expiresAtUnixMs, isUtc: true); } - Asset? get fee => _assetFromMoney(quotation?.expectedFeeTotal); Asset? get total => _assetFromMoney(quotation?.debitAmount); - Asset? get recipientGets => _assetFromMoney(quotation?.expectedSettlementAmount); + Asset? get recipientGets => + _assetFromMoney(quotation?.expectedSettlementAmount); Asset? _assetFromMoney(Money? money) { if (money == null) return null; @@ -88,26 +92,32 @@ class QuotationProvider extends ChangeNotifier { } Future getQuotation(PaymentIntent intent) async { - if (!_organizations.isOrganizationSet) throw StateError('Organization is not set'); + if (!_organizations.isOrganizationSet) { + throw StateError('Organization is not set'); + } _lastIntent = intent; try { _setResource(_quotation.copyWith(isLoading: true, error: null)); final response = await QuotationService.getQuotation( - _organizations.current.id, + _organizations.current.id, QuotePaymentRequest( idempotencyKey: Uuid().v4(), intent: intent.toDTO(), ), ); _isLoaded = true; - _setResource(_quotation.copyWith(data: response, isLoading: false, error: null)); + _setResource( + _quotation.copyWith(data: response, isLoading: false, error: null), + ); } catch (e, st) { _logger.warning('Failed to get quotation', e, st); - _setResource(_quotation.copyWith( - data: null, - error: toException(e), - isLoading: false, - )); + _setResource( + _quotation.copyWith( + data: null, + error: toException(e), + isLoading: false, + ), + ); } return _quotation.data; } diff --git a/frontend/pshared/lib/provider/payment/source.dart b/frontend/pshared/lib/provider/payment/source.dart new file mode 100644 index 00000000..4c704304 --- /dev/null +++ b/frontend/pshared/lib/provider/payment/source.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; + +import 'package:pshared/models/payment/source.dart'; +import 'package:pshared/provider/ledger.dart'; +import 'package:pshared/provider/payment/wallets.dart'; + +class PaymentSourceProvider extends ChangeNotifier { + List _sources = const []; + + List get sources => _sources; + + void update( + WalletsProvider walletsProvider, + LedgerAccountsProvider ledgerProvider, + ) { + final nextSources = [ + ...walletsProvider.wallets.map(PaymentSource.wallet), + ...ledgerProvider.accounts.map(PaymentSource.ledger), + ]; + + final currentKeys = _sources + .map((source) => source.key) + .toList(growable: false); + final nextKeys = nextSources + .map((source) => source.key) + .toList(growable: false); + + if (listEquals(currentKeys, nextKeys)) return; + + _sources = nextSources; + notifyListeners(); + } +} diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 6d95d8df..e582318c 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -5,6 +5,8 @@ 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/source.dart'; import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/provider/organizations.dart'; @@ -26,6 +28,7 @@ import 'package:pweb/pages/dashboard/dashboard.dart'; import 'package:pweb/pages/invitations/page.dart'; import 'package:pweb/pages/payment_methods/page.dart'; import 'package:pweb/pages/payout_page/page.dart'; +import 'package:pweb/pages/payout_page/wallet/edit/ledger_page.dart'; import 'package:pweb/pages/payout_page/wallet/edit/page.dart'; import 'package:pweb/pages/report/page.dart'; import 'package:pweb/pages/settings/profile/page.dart'; @@ -42,45 +45,80 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; RouteBase payoutShellRoute() => ShellRoute( builder: (context, state, child) => MultiProvider( providers: [ - ChangeNotifierProxyProvider2( + ChangeNotifierProxyProvider2< + OrganizationsProvider, + RecipientsProvider, + PaymentMethodsProvider + >( create: (_) => PaymentMethodsProvider(), - update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), + update: (context, organizations, recipients, provider) => + provider!..updateProviders(organizations, recipients), ), - ChangeNotifierProxyProvider2( + ChangeNotifierProxyProvider2< + OrganizationsProvider, + RecipientsProvider, + RecipientMethodsCacheProvider + >( create: (_) => RecipientMethodsCacheProvider(), - update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), + update: (context, organizations, recipients, provider) => + provider!..updateProviders(organizations, recipients), ), - ChangeNotifierProxyProvider2( - create: (_) => PaymentFlowProvider(initialType: enabledPaymentTypes.first), - update: (context, recipients, methods, provider) => provider!..update( + ChangeNotifierProxyProvider2< + RecipientsProvider, + PaymentMethodsProvider, + PaymentFlowProvider + >( + create: (_) => + PaymentFlowProvider(initialType: enabledPaymentTypes.first), + update: (context, recipients, methods, provider) => + provider!..update(recipients, methods), + ), + ChangeNotifierProvider(create: (_) => PaymentAmountProvider()), + ChangeNotifierProxyProvider6< + OrganizationsProvider, + PaymentAmountProvider, + PaymentSourceController, + PaymentFlowProvider, + RecipientsProvider, + PaymentMethodsProvider, + QuotationProvider + >( + create: (_) => QuotationProvider(), + update: + ( + _, + organization, + payment, + sources, + flow, + recipients, + methods, + provider, + ) => provider! + ..update( + organization, + payment, + sources, + flow, recipients, methods, ), ), - ChangeNotifierProvider( - create: (_) => PaymentAmountProvider(), - ), - ChangeNotifierProxyProvider6( - create: (_) => QuotationProvider(), - update: (_, organization, payment, wallet, flow, recipients, methods, provider) => - provider!..update(organization, payment, wallet, flow, recipients, methods), - ), ChangeNotifierProxyProvider( create: (_) => QuotationController(), update: (_, quotation, controller) => controller!..update(quotation), ), - ChangeNotifierProxyProvider2( + ChangeNotifierProxyProvider2< + OrganizationsProvider, + QuotationProvider, + PaymentProvider + >( create: (_) => PaymentProvider(), - update: (context, organization, quotation, provider) => provider!..update( - organization, - quotation, - ), + update: (context, organization, quotation, provider) => + provider!..update(organization, quotation), ), ], - child: PageSelector( - child: child, - routerState: state, - ), + child: PageSelector(child: child, routerState: state), ), routes: [ GoRoute( @@ -206,6 +244,11 @@ RouteBase payoutShellRoute() => ShellRoute( wallet, returnTo: PayoutDestination.methods, ), + onLedgerTap: (ledgerAccountRef) => _openLedgerEdit( + context, + ledgerAccountRef, + returnTo: PayoutDestination.methods, + ), ), ), ), @@ -213,8 +256,7 @@ RouteBase payoutShellRoute() => ShellRoute( name: PayoutRoutes.editWallet, path: PayoutRoutes.editWalletPath, pageBuilder: (context, state) { - final walletsProvider = context.read(); - final wallet = walletsProvider.selectedWallet; + final source = context.read().selectedSource; final loc = AppLocalizations.of(context)!; final fallbackDestination = PayoutRoutes.fallbackFromState( state, @@ -222,11 +264,15 @@ RouteBase payoutShellRoute() => ShellRoute( ); return NoTransitionPage( - child: wallet != null - ? WalletEditPage( + child: switch (source?.type) { + PaymentSourceType.wallet => WalletEditPage( onBack: () => _popOrGo(context, fallbackDestination), - ) - : Center(child: Text(loc.noWalletSelected)), + ), + PaymentSourceType.ledger => LedgerEditPage( + onBack: () => _popOrGo(context, fallbackDestination), + ), + null => Center(child: Text(loc.noWalletSelected)), + }, ); }, ), @@ -256,16 +302,10 @@ void _startPayment( required PayoutDestination returnTo, }) { context.read().setCurrentObject(recipient?.id); - context.pushToPayment( - paymentType: paymentType, - returnTo: returnTo, - ); + context.pushToPayment(paymentType: paymentType, returnTo: returnTo); } -void _openAddRecipient( - BuildContext context, { - Recipient? recipient, -}) { +void _openAddRecipient(BuildContext context, {Recipient? recipient}) { context.read().setCurrentObject(recipient?.id); context.pushNamed(PayoutRoutes.addRecipient); } @@ -276,6 +316,16 @@ void _openWalletEdit( required PayoutDestination returnTo, }) { context.read().selectWallet(wallet); + context.read().selectWalletByRef(wallet.id); + context.pushToEditWallet(returnTo: returnTo); +} + +void _openLedgerEdit( + BuildContext context, + String ledgerAccountRef, { + required PayoutDestination returnTo, +}) { + context.read().selectLedgerByRef(ledgerAccountRef); context.pushToEditWallet(returnTo: returnTo); } @@ -285,6 +335,7 @@ void _openWalletTopUp( required PayoutDestination returnTo, }) { context.read().selectWallet(wallet); + context.read().selectWalletByRef(wallet.id); context.pushToWalletTopUp(returnTo: returnTo); } diff --git a/frontend/pweb/lib/main.dart b/frontend/pweb/lib/main.dart index 9e2570b6..41ea7a33 100644 --- a/frontend/pweb/lib/main.dart +++ b/frontend/pweb/lib/main.dart @@ -10,6 +10,7 @@ import 'package:logging/logging.dart'; import 'package:pshared/config/constants.dart'; import 'package:pshared/controllers/balance_mask/ledger_accounts.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/provider/locale.dart'; import 'package:pshared/provider/permissions.dart'; import 'package:pshared/provider/account.dart'; @@ -19,6 +20,7 @@ import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/email_verification.dart'; import 'package:pshared/provider/ledger.dart'; +import 'package:pshared/provider/payment/source.dart'; import 'package:pshared/provider/payment/wallets.dart'; import 'package:pshared/provider/payment/payments.dart'; import 'package:pshared/provider/invitations.dart'; @@ -28,20 +30,23 @@ import 'package:pshared/service/payment/wallets.dart'; import 'package:pweb/app/app.dart'; import 'package:pweb/pages/invitations/widgets/list/view_model.dart'; import 'package:pweb/app/timeago.dart'; +import 'package:pweb/providers/operatioins.dart'; import 'package:pweb/providers/two_factor.dart'; import 'package:pweb/providers/upload_history.dart'; import 'package:pweb/providers/wallet_transactions.dart'; +import 'package:pweb/services/operations.dart'; import 'package:pweb/services/payments/history.dart'; import 'package:pweb/services/posthog.dart'; import 'package:pweb/services/wallet_transactions.dart'; import 'package:pweb/providers/account.dart'; - void _setupLogging() { - Logger.root.level = Level.ALL; + Logger.root.level = Level.ALL; Logger.root.onRecord.listen((record) { // ignore: avoid_print - print('${record.level.name}: ${record.time}: ${record.loggerName}: ${record.message}'); + print( + '${record.level.name}: ${record.time}: ${record.loggerName}: ${record.message}', + ); }); } @@ -50,7 +55,6 @@ void main() async { await Constants.initialize(); await PosthogService.initialize(); - _setupLogging(); setUrlStrategy(PathUrlStrategy()); @@ -62,54 +66,74 @@ void main() async { ChangeNotifierProvider(create: (_) => LocaleProvider(null)), ChangeNotifierProxyProvider( create: (_) => PwebAccountProvider(), - update: (context, localeProvider, provider) => provider!..updateProvider(localeProvider), + update: (context, localeProvider, provider) => + provider!..updateProvider(localeProvider), ), ChangeNotifierProxyProvider( create: (_) => TwoFactorProvider(), - update: (context, accountProvider, provider) => provider!..update(accountProvider), + update: (context, accountProvider, provider) => + provider!..update(accountProvider), ), ChangeNotifierProvider(create: (_) => OrganizationsProvider()), ChangeNotifierProxyProvider( create: (_) => PermissionsProvider(), - update: (context, orgnization, provider) => provider!..update(orgnization), + update: (context, orgnization, provider) => + provider!..update(orgnization), ), ChangeNotifierProxyProvider( create: (_) => EmployeesProvider(), - update: (context, organizations, provider) => provider!..updateProviders(organizations), + update: (context, organizations, provider) => + provider!..updateProviders(organizations), ), ChangeNotifierProxyProvider( create: (_) => PaymentsProvider(), - update: (context, organizations, provider) => provider!..update(organizations), + update: (context, organizations, provider) => + provider!..update(organizations), ), ChangeNotifierProvider(create: (_) => EmailVerificationProvider()), ChangeNotifierProvider( - create: (_) => UploadHistoryProvider(service: MockUploadHistoryService())..load(), + create: (_) => + UploadHistoryProvider(service: MockUploadHistoryService()) + ..load(), ), ChangeNotifierProxyProvider( create: (_) => RecipientsProvider(), - update: (context, organizations, provider) => provider!..updateProviders(organizations), + update: (context, organizations, provider) => + provider!..updateProviders(organizations), ), ChangeNotifierProxyProvider( create: (_) => InvitationsProvider(), - update: (context, organizations, provider) => provider!..updateProviders(organizations), + update: (context, organizations, provider) => + provider!..updateProviders(organizations), ), - ChangeNotifierProxyProvider2( - create: (_) => PaymentMethodsProvider(), - update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients), - ), - ChangeNotifierProvider( - create: (_) => InvitationListViewModel(), + ChangeNotifierProxyProvider2< + OrganizationsProvider, + RecipientsProvider, + PaymentMethodsProvider + >( + create: (_) => PaymentMethodsProvider(), + update: (context, organizations, recipients, provider) => + provider!..updateProviders(organizations, recipients), ), + ChangeNotifierProvider(create: (_) => InvitationListViewModel()), ChangeNotifierProxyProvider( create: (_) => WalletsProvider(ApiWalletsService()), - update: (context, organizations, provider) => provider!..update(organizations), + update: (context, organizations, provider) => + provider!..update(organizations), ), - ChangeNotifierProxyProvider( + ChangeNotifierProxyProvider< + OrganizationsProvider, + LedgerAccountsProvider + >( create: (_) => LedgerAccountsProvider(LedgerService()), - update: (context, organizations, provider) => provider!..update(organizations), + update: (context, organizations, provider) => + provider!..update(organizations), ), - ChangeNotifierProxyProvider( + ChangeNotifierProxyProvider< + LedgerAccountsProvider, + LedgerBalanceMaskController + >( create: (_) => LedgerBalanceMaskController(), update: (context, ledger, controller) => controller!..update(ledger), ), @@ -117,12 +141,33 @@ void main() async { create: (_) => WalletsController(), update: (_, wallets, controller) => controller!..update(wallets), ), + ChangeNotifierProxyProvider2< + WalletsProvider, + LedgerAccountsProvider, + PaymentSourceProvider + >( + create: (_) => PaymentSourceProvider(), + update: (_, wallets, ledger, provider) => + provider!..update(wallets, ledger), + ), + ChangeNotifierProxyProvider< + PaymentSourceProvider, + PaymentSourceController + >( + create: (_) => PaymentSourceController(), + update: (_, sources, controller) => controller!..update(sources), + ), ChangeNotifierProvider( - create: (_) => WalletTransactionsProvider(MockWalletTransactionsService())..load(), + create: (_) => + WalletTransactionsProvider(MockWalletTransactionsService()) + ..load(), + ), + ChangeNotifierProvider( + create: (_) => + OperationProvider(OperationService())..loadOperations(), ), ], child: const PayApp(), ), - ); } diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart index eb88511d..81ea5a3d 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/balance.dart @@ -16,10 +16,92 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class BalanceWidget extends StatelessWidget { final ValueChanged onTopUp; - const BalanceWidget({ - super.key, - required this.onTopUp, - }); + const BalanceWidget({super.key, required this.onTopUp}); + + @override + Widget build(BuildContext context) => _BalanceWidgetBody(onTopUp: onTopUp); +} + +class _BalanceWidgetBody extends StatefulWidget { + final ValueChanged onTopUp; + + const _BalanceWidgetBody({required this.onTopUp}); + + @override + State<_BalanceWidgetBody> createState() => _BalanceWidgetBodyState(); +} + +class _BalanceWidgetBodyState extends State<_BalanceWidgetBody> { + WalletsController? _walletsController; + LedgerAccountsProvider? _ledgerProvider; + CarouselIndexController? _carouselController; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextWallets = context.read(); + final nextLedger = context.read(); + final nextCarousel = context.read(); + + if (!identical(_walletsController, nextWallets)) { + _walletsController?.removeListener(_syncSelection); + _walletsController = nextWallets; + _walletsController?.addListener(_syncSelection); + } + + if (!identical(_ledgerProvider, nextLedger)) { + _ledgerProvider?.removeListener(_syncSelection); + _ledgerProvider = nextLedger; + _ledgerProvider?.addListener(_syncSelection); + } + + if (!identical(_carouselController, nextCarousel)) { + _carouselController?.removeListener(_syncSelection); + _carouselController = nextCarousel; + _carouselController?.addListener(_syncSelection); + } + + WidgetsBinding.instance.addPostFrameCallback((_) => _syncSelection()); + } + + @override + void dispose() { + _walletsController?.removeListener(_syncSelection); + _ledgerProvider?.removeListener(_syncSelection); + _carouselController?.removeListener(_syncSelection); + super.dispose(); + } + + void _syncSelection() { + final walletsController = _walletsController; + final carousel = _carouselController; + final ledgerProvider = _ledgerProvider; + if (walletsController == null || + carousel == null || + ledgerProvider == null) { + return; + } + + final items = [ + ...walletsController.wallets.map(BalanceItem.wallet), + ...ledgerProvider.accounts.map(BalanceItem.ledger), + const BalanceItem.addAction(), + ]; + if (items.isEmpty) return; + + final safeIndex = carousel.index.clamp(0, items.length - 1); + if (safeIndex != carousel.index) { + carousel.setIndex(safeIndex, items.length); + return; + } + + final current = items[safeIndex]; + if (!current.isWallet) return; + final wallet = current.wallet!; + if (walletsController.selectedWallet?.id != wallet.id) { + walletsController.selectWallet(wallet); + } + } @override Widget build(BuildContext context) { @@ -30,7 +112,8 @@ class BalanceWidget extends StatelessWidget { final wallets = walletsController.wallets; final accounts = ledgerProvider.accounts; - final isLoading = walletsController.isLoading && + final isLoading = + walletsController.isLoading && ledgerProvider.isLoading && wallets.isEmpty && accounts.isEmpty; @@ -49,19 +132,7 @@ class BalanceWidget extends StatelessWidget { 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 index = carousel.index.clamp(0, items.length - 1); final carouselWidget = BalanceCarousel( items: items, @@ -73,7 +144,7 @@ class BalanceWidget extends StatelessWidget { walletsController.selectWallet(next.wallet!); } }, - onTopUp: onTopUp, + onTopUp: widget.onTopUp, ); if (wallets.isEmpty && accounts.isEmpty) { diff --git a/frontend/pweb/lib/pages/dashboard/payouts/amount.dart b/frontend/pweb/lib/pages/dashboard/payouts/amount.dart index 3e2b2b27..f5699f7c 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/amount.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/amount.dart @@ -2,12 +2,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:pshared/controllers/payment/source.dart'; +import 'package:pshared/models/payment/source.dart'; import 'package:pshared/provider/payment/amount.dart'; import 'package:pshared/utils/currency.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - class PaymentAmountWidget extends StatefulWidget { const PaymentAmountWidget({super.key}); @@ -32,7 +33,8 @@ class _PaymentAmountWidgetState extends State { super.dispose(); } - double? _parseAmount(String value) => double.tryParse(value.replaceAll(',', '.')); + double? _parseAmount(String value) => + double.tryParse(value.replaceAll(',', '.')); void _syncTextWithAmount(double amount) { final parsedText = _parseAmount(_controller.text); @@ -58,14 +60,28 @@ class _PaymentAmountWidgetState extends State { @override Widget build(BuildContext context) { - final amount = context.select((provider) => provider.amount); + final amount = context.select( + (provider) => provider.amount, + ); + final source = context.watch().selectedSource; _syncTextWithAmount(amount); + final sourceCurrency = switch (source?.type) { + null => null, + PaymentSourceType.wallet => currencyCodeToString( + source!.wallet!.currency, + ), + PaymentSourceType.ledger => + source!.ledgerAccount?.currency.trim().toUpperCase(), + }; + final amountLabel = sourceCurrency == null || sourceCurrency.isEmpty + ? AppLocalizations.of(context)!.amount + : '${AppLocalizations.of(context)!.amount} ($sourceCurrency)'; return TextField( controller: _controller, keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.amount, + labelText: amountLabel, border: const OutlineInputBorder(), ), onChanged: _onChanged, diff --git a/frontend/pweb/lib/pages/dashboard/payouts/summary/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/summary/widget.dart index a0dc5257..260c807b 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/summary/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/summary/widget.dart @@ -2,7 +2,9 @@ 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/models/currency.dart'; +import 'package:pshared/models/payment/source.dart'; import 'package:pshared/utils/currency.dart'; import 'package:pweb/pages/dashboard/payouts/summary/fee.dart'; @@ -10,24 +12,43 @@ import 'package:pweb/pages/dashboard/payouts/summary/recipient_receives.dart'; import 'package:pweb/pages/dashboard/payouts/summary/sent_amount.dart'; import 'package:pweb/pages/dashboard/payouts/summary/total.dart'; - class PaymentSummary extends StatelessWidget { final double spacing; const PaymentSummary({super.key, required this.spacing}); + Currency _currencyForSource(PaymentSource? source) { + if (source == null) return Currency.usdt; + return switch (source.type) { + PaymentSourceType.wallet => source.wallet!.currency, + PaymentSourceType.ledger => () { + final code = source.ledgerAccount?.currency.trim().toUpperCase() ?? ''; + try { + return currencyStringToCode(code); + } catch (_) { + return Currency.rub; + } + }(), + }; + } + @override - Widget build(BuildContext context) => Align( - alignment: Alignment.center, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - PaymentSentAmountRow(currency: currencyStringToCode(context.read().selectedWallet?.tokenSymbol ?? 'USDT')), - const PaymentFeeRow(), - const PaymentRecipientReceivesRow(), - SizedBox(height: spacing), - const PaymentTotalRow(), - ], - ), - ); + Widget build(BuildContext context) { + final source = context.watch().selectedSource; + final sentCurrency = _currencyForSource(source); + + return Align( + alignment: Alignment.center, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PaymentSentAmountRow(currency: sentCurrency), + const PaymentFeeRow(), + const PaymentRecipientReceivesRow(), + SizedBox(height: spacing), + const PaymentTotalRow(), + ], + ), + ); + } } diff --git a/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart b/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart index 72d59968..aa2091df 100644 --- a/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart +++ b/frontend/pweb/lib/pages/invitations/widgets/form/fields.dart @@ -79,9 +79,9 @@ class InvitationFormFields extends StatelessWidget { SizedBox( width: _fieldWidth, child: TextFormField( - controller: firstNameController, + controller: lastNameController, decoration: InputDecoration( - labelText: loc.firstName, + labelText: loc.lastName, prefixIcon: const Icon(Icons.person_outline), ), ), @@ -89,9 +89,9 @@ class InvitationFormFields extends StatelessWidget { SizedBox( width: _fieldWidth, child: TextFormField( - controller: lastNameController, + controller: firstNameController, decoration: InputDecoration( - labelText: loc.lastName, + labelText: loc.firstName, prefixIcon: const Icon(Icons.person_outline), ), ), diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart index 6b1770de..82892b6b 100644 --- a/frontend/pweb/lib/pages/payment_methods/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/page.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/provider/payment/flow.dart'; import 'package:pshared/provider/payment/provider.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; @@ -45,7 +45,9 @@ class _PaymentPageState extends State { _searchController = TextEditingController(); _searchFocusNode = FocusNode(); - WidgetsBinding.instance.addPostFrameCallback((_) => _initializePaymentPage()); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _initializePaymentPage(), + ); } @override @@ -126,7 +128,7 @@ class _PaymentPageState extends State { searchQuery: _query, filteredRecipients: filteredRecipients, methodsProvider: methodsProvider, - onWalletSelected: context.read().selectWallet, + onSourceSelected: context.read().selectSource, searchController: _searchController, searchFocusNode: _searchFocusNode, onSearchChanged: _handleSearchChanged, diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/body.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/body.dart index 954d360d..1afbd31e 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/body.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/body.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/recipient/recipient.dart'; -import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/models/payment/source.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/provider.dart'; @@ -11,7 +11,6 @@ import 'package:pweb/pages/payment_methods/payment_page/page.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - class PaymentPageBody extends StatelessWidget { final ValueChanged? onBack; final Recipient? recipient; @@ -20,7 +19,7 @@ class PaymentPageBody extends StatelessWidget { final String searchQuery; final List filteredRecipients; final PaymentMethodsProvider methodsProvider; - final ValueChanged onWalletSelected; + final ValueChanged onSourceSelected; final PayoutDestination fallbackDestination; final TextEditingController searchController; final FocusNode searchFocusNode; @@ -38,7 +37,7 @@ class PaymentPageBody extends StatelessWidget { required this.searchQuery, required this.filteredRecipients, required this.methodsProvider, - required this.onWalletSelected, + required this.onSourceSelected, required this.fallbackDestination, required this.searchController, required this.searchFocusNode, @@ -58,7 +57,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, + ), ); } @@ -69,7 +70,7 @@ class PaymentPageBody extends StatelessWidget { recipientProvider: recipientProvider, searchQuery: searchQuery, filteredRecipients: filteredRecipients, - onWalletSelected: onWalletSelected, + onSourceSelected: onSourceSelected, fallbackDestination: fallbackDestination, searchController: searchController, searchFocusNode: searchFocusNode, diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart deleted file mode 100644 index c2f52577..00000000 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/content.dart +++ /dev/null @@ -1,134 +0,0 @@ -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/models/recipient/recipient.dart'; -import 'package:pshared/provider/recipient/provider.dart'; - -import 'package:pweb/pages/payment_methods/payment_page/back_button.dart'; -import 'package:pweb/pages/payment_methods/payment_page/header.dart'; -import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart'; -import 'package:pweb/pages/payment_methods/payment_page/send_button.dart'; -import 'package:pweb/pages/dashboard/payouts/form.dart'; -import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart'; -import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart'; -import 'package:pweb/pages/payment_methods/widgets/section_title.dart'; -import 'package:pweb/utils/dimensions.dart'; -import 'package:pweb/widgets/sidebar/destinations.dart'; -import 'package:pweb/widgets/refresh_balance/wallet.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -class PaymentPageContent extends StatelessWidget { - final ValueChanged? onBack; - final Recipient? recipient; - final Recipient? previousRecipient; - final RecipientsProvider recipientProvider; - final String searchQuery; - final List filteredRecipients; - final ValueChanged onWalletSelected; - final PayoutDestination fallbackDestination; - final TextEditingController searchController; - final FocusNode searchFocusNode; - final ValueChanged onSearchChanged; - final ValueChanged onRecipientSelected; - final VoidCallback onRecipientCleared; - final VoidCallback onSend; - - const PaymentPageContent({ - super.key, - required this.onBack, - required this.recipient, - required this.previousRecipient, - required this.recipientProvider, - required this.searchQuery, - required this.filteredRecipients, - required this.onWalletSelected, - required this.fallbackDestination, - required this.searchController, - required this.searchFocusNode, - required this.onSearchChanged, - required this.onRecipientSelected, - required this.onRecipientCleared, - required this.onSend, - }); - - @override - Widget build(BuildContext context) { - final dimensions = AppDimensions(); - final loc = AppLocalizations.of(context)!; - - return Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: dimensions.maxContentWidth), - child: Material( - elevation: dimensions.elevationSmall, - borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium), - color: Theme.of(context).colorScheme.onSecondary, - child: Padding( - padding: EdgeInsets.all(dimensions.paddingLarge), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PaymentBackButton( - onBack: onBack, - recipient: recipient, - fallbackDestination: fallbackDestination, - ), - SizedBox(height: dimensions.paddingSmall), - PaymentHeader(), - SizedBox(height: dimensions.paddingXXLarge), - Row( - children: [ - Expanded(child: SectionTitle(loc.sourceOfFunds)), - Consumer( - builder: (context, provider, _) { - final selectedWalletId = provider.selectedWallet?.id; - if (selectedWalletId == null) { - return const SizedBox.shrink(); - } - return WalletBalanceRefreshButton(walletRef: selectedWalletId); - }, - ), - ], - ), - SizedBox(height: dimensions.paddingSmall), - PaymentMethodSelector( - onMethodChanged: onWalletSelected, - ), - SizedBox(height: dimensions.paddingXLarge), - RecipientSection( - recipient: recipient, - previousRecipient: previousRecipient, - dimensions: dimensions, - recipientProvider: recipientProvider, - searchQuery: searchQuery, - filteredRecipients: filteredRecipients, - searchController: searchController, - searchFocusNode: searchFocusNode, - onSearchChanged: onSearchChanged, - onRecipientSelected: onRecipientSelected, - onRecipientCleared: onRecipientCleared, - ), - SizedBox(height: dimensions.paddingXLarge), - PaymentInfoSection(dimensions: dimensions), - SizedBox(height: dimensions.paddingLarge), - const PaymentFormWidget(), - SizedBox(height: dimensions.paddingXXXLarge), - SendButton(onPressed: onSend), - SizedBox(height: dimensions.paddingLarge), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/method_selector.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/method_selector.dart index 3d6fdc08..7287188a 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/method_selector.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/method_selector.dart @@ -2,26 +2,74 @@ 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:pweb/utils/payment/dropdown.dart'; +import 'package:pshared/controllers/payment/source.dart'; +import 'package:pshared/models/payment/source.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; class PaymentMethodSelector extends StatelessWidget { - final ValueChanged onMethodChanged; + final ValueChanged onMethodChanged; - const PaymentMethodSelector({ + const PaymentMethodSelector({super.key, required this.onMethodChanged}); + + @override + Widget build(BuildContext context) => Consumer( + builder: (context, provider, _) => PaymentMethodDropdown( + methods: provider.sources, + selectedMethod: provider.selectedSource, + onChanged: onMethodChanged, + ), + ); +} + +class PaymentMethodDropdown extends StatelessWidget { + final List methods; + final ValueChanged onChanged; + final PaymentSource? selectedMethod; + + const PaymentMethodDropdown({ super.key, - required this.onMethodChanged, + required this.methods, + required this.onChanged, + this.selectedMethod, }); @override - Widget build(BuildContext context) => Consumer( - builder: (context, provider, _) => PaymentMethodDropdown( - methods: provider.wallets, - selectedMethod: provider.selectedWallet, - onChanged: onMethodChanged, - ), - ); + Widget build(BuildContext context) => DropdownButtonFormField( + dropdownColor: Theme.of(context).colorScheme.onSecondary, + initialValue: _getSelectedMethod(), + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.whereGetMoney, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + items: methods + .map( + (method) => DropdownMenuItem( + value: method, + child: Text(_labelForSource(context, method)), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + onChanged(value); + } + }, + ); + + PaymentSource? _getSelectedMethod() { + if (selectedMethod != null) return selectedMethod; + if (methods.isEmpty) return null; + return methods.first; + } + + String _labelForSource(BuildContext context, PaymentSource source) { + final name = source.name.trim(); + final loc = AppLocalizations.of(context)!; + if (name.isNotEmpty) return name; + return switch (source.type) { + PaymentSourceType.wallet => loc.paymentTypeManagedWallet, + PaymentSourceType.ledger => loc.paymentTypeLedger, + }; + } } diff --git a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart index 5d7d4ded..835e7ef8 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/models/payment/source.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/provider/recipient/provider.dart'; @@ -17,7 +17,6 @@ import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - class PaymentPageContent extends StatelessWidget { final ValueChanged? onBack; final Recipient? recipient; @@ -25,7 +24,7 @@ class PaymentPageContent extends StatelessWidget { final RecipientsProvider recipientProvider; final String searchQuery; final List filteredRecipients; - final ValueChanged onWalletSelected; + final ValueChanged onSourceSelected; final PayoutDestination fallbackDestination; final TextEditingController searchController; final FocusNode searchFocusNode; @@ -42,7 +41,7 @@ class PaymentPageContent extends StatelessWidget { required this.recipientProvider, required this.searchQuery, required this.filteredRecipients, - required this.onWalletSelected, + required this.onSourceSelected, required this.fallbackDestination, required this.searchController, required this.searchFocusNode, @@ -56,7 +55,7 @@ class PaymentPageContent extends StatelessWidget { Widget build(BuildContext context) { final dimensions = AppDimensions(); final loc = AppLocalizations.of(context)!; - + return Align( alignment: Alignment.topCenter, child: ConstrainedBox( @@ -82,9 +81,7 @@ class PaymentPageContent extends StatelessWidget { SizedBox(height: dimensions.paddingXXLarge), SectionTitle(loc.sourceOfFunds), SizedBox(height: dimensions.paddingSmall), - PaymentMethodSelector( - onMethodChanged: onWalletSelected, - ), + PaymentMethodSelector(onMethodChanged: onSourceSelected), SizedBox(height: dimensions.paddingXLarge), RecipientSection( recipient: recipient, diff --git a/frontend/pweb/lib/pages/payout_page/page.dart b/frontend/pweb/lib/pages/payout_page/page.dart index 559a65d1..3d31abc0 100644 --- a/frontend/pweb/lib/pages/payout_page/page.dart +++ b/frontend/pweb/lib/pages/payout_page/page.dart @@ -10,11 +10,15 @@ import 'package:pweb/pages/payout_page/wallet/wigets.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - class PaymentConfigPage extends StatelessWidget { final Function(Wallet) onWalletTap; + final Function(String ledgerAccountRef) onLedgerTap; - const PaymentConfigPage({super.key, required this.onWalletTap}); + const PaymentConfigPage({ + super.key, + required this.onWalletTap, + required this.onLedgerTap, + }); @override Widget build(BuildContext context) { @@ -26,14 +30,21 @@ class PaymentConfigPage extends StatelessWidget { } if (provider.error != null) { - return Center(child: Text(loc.notificationError(provider.error ?? loc.noErrorInformation))); + return Center( + child: Text( + loc.notificationError(provider.error ?? loc.noErrorInformation), + ), + ); } return Column( children: [ MethodsWidget(), Expanded( - child: WalletWidgets(onWalletTap: onWalletTap), + child: WalletWidgets( + onWalletTap: onWalletTap, + onLedgerTap: onLedgerTap, + ), ), ], ); diff --git a/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/buttons.dart b/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/buttons.dart index 4280a9b7..9ac6b0f3 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/buttons.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/buttons.dart @@ -1,10 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - import 'package:pweb/pages/payout_page/wallet/edit/buttons/send.dart'; import 'package:pweb/pages/payout_page/wallet/edit/buttons/top_up.dart'; -import 'package:pshared/provider/payment/wallets.dart'; class ButtonsWalletWidget extends StatelessWidget { @@ -12,25 +9,17 @@ class ButtonsWalletWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final provider = context.watch(); - - if (provider.wallets.isEmpty) return const SizedBox.shrink(); - return Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Expanded( - child: SendPayoutButton(), - ), - VerticalDivider( - color: Theme.of(context).colorScheme.primary, - thickness: 1, - width: 10, - ), - Expanded( - child: TopUpButton(), - ), - ], + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Expanded(child: SendPayoutButton()), + VerticalDivider( + color: Theme.of(context).colorScheme.primary, + thickness: 1, + width: 10, + ), + Expanded(child: TopUpButton()), + ], ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/send.dart b/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/send.dart index 29d64956..f9f78591 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/send.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/send.dart @@ -2,7 +2,8 @@ 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/models/payment/source.dart'; import 'package:pshared/models/payment/type.dart'; import 'package:pweb/app/router/payout_routes.dart'; @@ -18,20 +19,18 @@ class SendPayoutButton extends StatelessWidget { Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; return ElevatedButton( - style: ElevatedButton.styleFrom( - shadowColor: null, - elevation: 0, - ), + style: ElevatedButton.styleFrom(shadowColor: null, elevation: 0), onPressed: () { - final wallets = context.read(); - final wallet = wallets.selectedWallet; - - if (wallet != null) { - context.pushToPayment( - paymentType: PaymentType.wallet, - returnTo: PayoutDestination.editwallet, - ); - } + final source = context.read().selectedSource; + if (source == null) return; + final paymentType = switch (source.type) { + PaymentSourceType.wallet => PaymentType.wallet, + PaymentSourceType.ledger => PaymentType.ledger, + }; + context.pushToPayment( + paymentType: paymentType, + returnTo: PayoutDestination.editwallet, + ); }, child: Text(loc.payoutNavSendPayout), ); diff --git a/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/top_up.dart b/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/top_up.dart index f7573dad..10aaf541 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/top_up.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/edit/buttons/top_up.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:pweb/app/router/payout_routes.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; @@ -10,23 +10,20 @@ import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; -class TopUpButton extends StatelessWidget{ +class TopUpButton extends StatelessWidget { const TopUpButton({super.key}); @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; return ElevatedButton( - style: ElevatedButton.styleFrom( - shadowColor: null, - elevation: 0, - ), + style: ElevatedButton.styleFrom(shadowColor: null, elevation: 0), onPressed: () { - final wallet = context.read().selectedWallet; - if (wallet == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(loc.noWalletSelected)), - ); + final source = context.read().selectedSource; + if (source == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(loc.noWalletSelected))); return; } context.pushToWalletTopUp(returnTo: PayoutDestination.editwallet); diff --git a/frontend/pweb/lib/pages/payout_page/wallet/edit/ledger/balance_row.dart b/frontend/pweb/lib/pages/payout_page/wallet/edit/ledger/balance_row.dart new file mode 100644 index 00000000..b3280e63 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/wallet/edit/ledger/balance_row.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/controllers/balance_mask/ledger_accounts.dart'; +import 'package:pshared/models/ledger/account.dart'; + +import 'package:pweb/pages/payout_page/wallet/ledger/format.dart'; +import 'package:pweb/widgets/refresh_balance/ledger.dart'; + + +class LedgerEditBalanceRow extends StatelessWidget { + final LedgerAccount account; + + const LedgerEditBalanceRow({super.key, required this.account}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Consumer( + builder: (context, controller, _) { + final isMasked = controller.isBalanceMasked(account.ledgerAccountRef); + final money = account.balance?.balance; + final displayBalance = money == null + ? '--' + : isMasked + ? formatMaskedLedgerBalance(money.currency) + : formatLedgerBalance( + amount: money.amount, + currency: money.currency, + ); + + return Row( + children: [ + Expanded( + child: Row( + children: [ + Text( + displayBalance, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () => + controller.toggleBalanceMask(account.ledgerAccountRef), + child: Icon( + isMasked ? Icons.visibility_off : Icons.visibility, + size: 24, + ), + ), + ], + ), + ), + LedgerBalanceRefreshButton( + ledgerAccountRef: account.ledgerAccountRef, + ), + ], + ); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/wallet/edit/ledger/copyable_row.dart b/frontend/pweb/lib/pages/payout_page/wallet/edit/ledger/copyable_row.dart new file mode 100644 index 00000000..a95bdba6 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/wallet/edit/ledger/copyable_row.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + + +class LedgerEditCopyableRow extends StatelessWidget { + final String title; + final String value; + + const LedgerEditCopyableRow({ + super.key, + required this.title, + required this.value, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.bodySmall), + const SizedBox(height: 2), + Text(value, style: theme.textTheme.bodyLarge), + ], + ), + ), + IconButton( + icon: const Icon(Icons.copy), + iconSize: 18, + onPressed: () => Clipboard.setData(ClipboardData(text: value)), + ), + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/wallet/edit/ledger/view.dart b/frontend/pweb/lib/pages/payout_page/wallet/edit/ledger/view.dart new file mode 100644 index 00000000..4a2a3664 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/wallet/edit/ledger/view.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/ledger/account.dart'; + +import 'package:pweb/pages/payout_page/wallet/edit/buttons/buttons.dart'; +import 'package:pweb/pages/payout_page/wallet/edit/ledger/balance_row.dart'; +import 'package:pweb/pages/payout_page/wallet/edit/ledger/copyable_row.dart'; +import 'package:pweb/utils/dimensions.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class LedgerEditView extends StatelessWidget { + final LedgerAccount account; + final VoidCallback onBack; + + const LedgerEditView({ + super.key, + required this.account, + required this.onBack, + }); + + @override + Widget build(BuildContext context) { + final dimensions = AppDimensions(); + final loc = AppLocalizations.of(context)!; + final theme = Theme.of(context); + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: dimensions.maxContentWidth), + child: Material( + elevation: dimensions.elevationSmall, + color: theme.colorScheme.onSecondary, + borderRadius: BorderRadius.circular(dimensions.borderRadiusMedium), + child: Padding( + padding: EdgeInsets.all(dimensions.paddingLarge), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBack, + ), + Text( + loc.paymentTypeLedger, + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + if (account.currency.trim().isNotEmpty) + Text( + account.currency, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + LedgerEditBalanceRow(account: account), + const SizedBox(height: 12), + LedgerEditCopyableRow( + title: loc.ledgerAccountRef, + value: account.ledgerAccountRef, + ), + const SizedBox(height: 8), + LedgerEditCopyableRow( + title: 'Account code', + value: account.accountCode, + ), + const SizedBox(height: 24), + const ButtonsWalletWidget(), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/wallet/edit/ledger_page.dart b/frontend/pweb/lib/pages/payout_page/wallet/edit/ledger_page.dart new file mode 100644 index 00000000..a45569bc --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/wallet/edit/ledger_page.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:provider/provider.dart'; + +import 'package:pshared/controllers/payment/source.dart'; +import 'package:pshared/provider/ledger.dart'; + +import 'package:pweb/pages/payout_page/wallet/edit/ledger/view.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + +class LedgerEditPage extends StatelessWidget { + final VoidCallback onBack; + + const LedgerEditPage({super.key, required this.onBack}); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final source = context.watch().selectedSource; + + if (source == null || source.ledgerAccount == null) { + return Center(child: Text(loc.noWalletSelected)); + } + + final accountRef = source.ledgerAccount!.ledgerAccountRef; + + return Consumer( + builder: (context, provider, _) { + final account = provider.accounts.firstWhereOrNull( + (item) => item.ledgerAccountRef == accountRef, + ); + if (account == null) { + return Center(child: Text(loc.noWalletSelected)); + } + + return LedgerEditView(account: account, onBack: onBack); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/wallet/ledger/card.dart b/frontend/pweb/lib/pages/payout_page/wallet/ledger/card.dart new file mode 100644 index 00000000..8b76136a --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/wallet/ledger/card.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/ledger/account.dart'; + +import 'package:pweb/pages/payout_page/wallet/ledger/card_body.dart'; + + +class LedgerWalletCard extends StatelessWidget { + final LedgerAccount account; + final VoidCallback onTap; + + const LedgerWalletCard({ + super.key, + required this.account, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: theme.cardTheme.elevation ?? 4, + color: theme.colorScheme.onSecondary, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Container( + padding: const EdgeInsets.only(left: 50, top: 16, bottom: 16), + child: Row( + spacing: 3, + children: [ + const CircleAvatar( + radius: 24, + child: Icon(Icons.account_balance, size: 28), + ), + const SizedBox(width: 16), + Expanded(child: LedgerCardBody(account: account)), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/wallet/ledger/card_body.dart b/frontend/pweb/lib/pages/payout_page/wallet/ledger/card_body.dart new file mode 100644 index 00000000..abd28407 --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/wallet/ledger/card_body.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pshared/controllers/balance_mask/ledger_accounts.dart'; +import 'package:pshared/models/ledger/account.dart'; + +import 'package:pweb/pages/payout_page/wallet/ledger/format.dart'; +import 'package:pweb/widgets/refresh_balance/ledger.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class LedgerCardBody extends StatelessWidget { + final LedgerAccount account; + + const LedgerCardBody({super.key, required this.account}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context)!; + + return Consumer( + builder: (context, controller, _) { + final isMasked = controller.isBalanceMasked(account.ledgerAccountRef); + final money = account.balance?.balance; + final displayBalance = money == null + ? '--' + : isMasked + ? formatMaskedLedgerBalance(money.currency) + : formatLedgerBalance( + amount: money.amount, + currency: money.currency, + ); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + displayBalance, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () => controller.toggleBalanceMask( + account.ledgerAccountRef, + ), + child: Icon( + isMasked ? Icons.visibility_off : Icons.visibility, + size: 24, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + LedgerBalanceRefreshButton( + ledgerAccountRef: account.ledgerAccountRef, + ), + ], + ), + Text( + loc.paymentTypeLedger, + style: theme.textTheme.bodyLarge!.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + account.accountCode, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/payout_page/wallet/ledger/format.dart b/frontend/pweb/lib/pages/payout_page/wallet/ledger/format.dart new file mode 100644 index 00000000..2de928bc --- /dev/null +++ b/frontend/pweb/lib/pages/payout_page/wallet/ledger/format.dart @@ -0,0 +1,27 @@ +import 'package:pshared/utils/currency.dart'; + + +String formatLedgerBalance({required String amount, required String currency}) { + final parsed = double.tryParse(amount); + if (parsed == null) return '$amount $currency'; + + try { + final symbol = currencyCodeToSymbol(currencyStringToCode(currency)); + if (symbol.trim().isEmpty) return '${amountToString(parsed)} $currency'; + return '${amountToString(parsed)} $symbol'; + } catch (_) { + return '${amountToString(parsed)} $currency'; + } +} + +String formatMaskedLedgerBalance(String currency) { + final normalized = currency.trim(); + if (normalized.isEmpty) return '••••'; + try { + final symbol = currencyCodeToSymbol(currencyStringToCode(normalized)); + if (symbol.trim().isEmpty) return '•••• $normalized'; + return '•••• $symbol'; + } catch (_) { + return '•••• $normalized'; + } +} diff --git a/frontend/pweb/lib/pages/payout_page/wallet/wigets.dart b/frontend/pweb/lib/pages/payout_page/wallet/wigets.dart index 58cdaae3..64e28d5d 100644 --- a/frontend/pweb/lib/pages/payout_page/wallet/wigets.dart +++ b/frontend/pweb/lib/pages/payout_page/wallet/wigets.dart @@ -3,42 +3,58 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pshared/models/payment/wallet.dart'; +import 'package:pshared/provider/ledger.dart'; import 'package:pshared/provider/payment/wallets.dart'; import 'package:pweb/pages/payout_page/wallet/card.dart'; - +import 'package:pweb/pages/payout_page/wallet/ledger/card.dart'; class WalletWidgets extends StatelessWidget { final void Function(Wallet) onWalletTap; + final void Function(String ledgerAccountRef) onLedgerTap; - const WalletWidgets({super.key, required this.onWalletTap}); + const WalletWidgets({ + super.key, + required this.onWalletTap, + required this.onLedgerTap, + }); @override Widget build(BuildContext context) { final provider = context.watch(); + final ledgerProvider = context.watch(); final wallets = provider.wallets; + final accounts = ledgerProvider.accounts; return GridView.builder( scrollDirection: Axis.vertical, - physics: AlwaysScrollableScrollPhysics(), + physics: const AlwaysScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 3, ), - itemCount: wallets.length, + itemCount: wallets.length + accounts.length, itemBuilder: (context, index) { - final wallet = wallets[index]; + if (index < wallets.length) { + final wallet = wallets[index]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: WalletCard(wallet: wallet, onTap: () => onWalletTap(wallet)), + ); + } + + final account = accounts[index - wallets.length]; return Padding( padding: const EdgeInsets.symmetric(vertical: 6.0), - child: WalletCard( - wallet: wallet, - onTap: () => onWalletTap(wallet), + child: LedgerWalletCard( + account: account, + onTap: () => onLedgerTap(account.ledgerAccountRef), ), ); }, ); } -} \ No newline at end of file +} diff --git a/frontend/pweb/lib/pages/wallet_top_up/header.dart b/frontend/pweb/lib/pages/wallet_top_up/header.dart index 78a76244..f0bf3b4c 100644 --- a/frontend/pweb/lib/pages/wallet_top_up/header.dart +++ b/frontend/pweb/lib/pages/wallet_top_up/header.dart @@ -2,15 +2,16 @@ import 'package:flutter/material.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - class WalletTopUpHeader extends StatelessWidget { final VoidCallback onBack; final String? tokenSymbol; + final String? sourceLabel; const WalletTopUpHeader({ super.key, required this.onBack, this.tokenSymbol, + this.sourceLabel, }); @override @@ -20,24 +21,18 @@ class WalletTopUpHeader extends StatelessWidget { final symbol = tokenSymbol?.trim(); final subtitle = [ - loc.paymentTypeCryptoWallet, + sourceLabel ?? loc.paymentTypeCryptoWallet, if (symbol != null && symbol.isNotEmpty) symbol, ].join(' · '); return Row( children: [ - IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: onBack, - ), + IconButton(icon: const Icon(Icons.arrow_back), onPressed: onBack), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - loc.walletTopUpTitle, - style: theme.textTheme.titleLarge, - ), + Text(loc.walletTopUpTitle, style: theme.textTheme.titleLarge), const SizedBox(height: 4), Text( subtitle, diff --git a/frontend/pweb/lib/pages/wallet_top_up/ledger_content.dart b/frontend/pweb/lib/pages/wallet_top_up/ledger_content.dart new file mode 100644 index 00000000..ff5ef68b --- /dev/null +++ b/frontend/pweb/lib/pages/wallet_top_up/ledger_content.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/ledger/account.dart'; + +import 'package:pweb/pages/wallet_top_up/details.dart'; +import 'package:pweb/pages/wallet_top_up/header.dart'; +import 'package:pweb/pages/wallet_top_up/meta.dart'; +import 'package:pweb/utils/dimensions.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class LedgerTopUpContent extends StatelessWidget { + final LedgerAccount account; + final VoidCallback onBack; + + const LedgerTopUpContent({ + super.key, + required this.account, + required this.onBack, + }); + + @override + Widget build(BuildContext context) { + final dimensions = AppDimensions(); + final theme = Theme.of(context); + final loc = AppLocalizations.of(context)!; + + return Align( + alignment: Alignment.topCenter, + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 960), + child: Padding( + padding: EdgeInsets.symmetric(vertical: dimensions.paddingLarge), + child: Material( + elevation: dimensions.elevationSmall, + color: theme.colorScheme.onSecondary, + borderRadius: BorderRadius.circular( + dimensions.borderRadiusMedium, + ), + child: Padding( + padding: EdgeInsets.all(dimensions.paddingXLarge), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + WalletTopUpHeader( + onBack: onBack, + tokenSymbol: account.currency, + sourceLabel: loc.paymentTypeLedger, + ), + SizedBox(height: dimensions.paddingLarge), + WalletTopUpMeta( + assetLabel: account.currency, + walletId: account.accountCode, + idLabel: loc.ledgerAccountRef, + ), + SizedBox(height: dimensions.paddingXLarge), + WalletTopUpDetails( + address: account.ledgerAccountRef, + dimensions: dimensions, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/wallet_top_up/meta.dart b/frontend/pweb/lib/pages/wallet_top_up/meta.dart index b9703d34..f7ad324c 100644 --- a/frontend/pweb/lib/pages/wallet_top_up/meta.dart +++ b/frontend/pweb/lib/pages/wallet_top_up/meta.dart @@ -10,12 +10,14 @@ class WalletTopUpMeta extends StatelessWidget { final String assetLabel; final String walletId; final String? network; + final String? idLabel; const WalletTopUpMeta({ super.key, required this.assetLabel, required this.walletId, this.network, + this.idLabel, }); @override @@ -27,10 +29,16 @@ class WalletTopUpMeta extends StatelessWidget { spacing: dimensions.paddingLarge, runSpacing: dimensions.paddingLarge, children: [ - WalletTopUpInfoChip(label: loc.walletTopUpAssetLabel, value: assetLabel), + WalletTopUpInfoChip( + label: loc.walletTopUpAssetLabel, + value: assetLabel, + ), if (network != null && network!.isNotEmpty) - WalletTopUpInfoChip(label: loc.walletTopUpNetworkLabel, value: network!), - WalletTopUpInfoChip(label: loc.walletId, value: walletId), + WalletTopUpInfoChip( + label: loc.walletTopUpNetworkLabel, + value: network!, + ), + WalletTopUpInfoChip(label: idLabel ?? loc.walletId, value: walletId), ], ); } diff --git a/frontend/pweb/lib/pages/wallet_top_up/page.dart b/frontend/pweb/lib/pages/wallet_top_up/page.dart index 934cf716..909abf3d 100644 --- a/frontend/pweb/lib/pages/wallet_top_up/page.dart +++ b/frontend/pweb/lib/pages/wallet_top_up/page.dart @@ -1,10 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:collection/collection.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/source.dart'; +import 'package:pshared/provider/ledger.dart'; import 'package:pweb/pages/wallet_top_up/content.dart'; +import 'package:pweb/pages/wallet_top_up/ledger_content.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -17,27 +22,55 @@ class WalletTopUpPage extends StatelessWidget { @override Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; + final source = context.watch().selectedSource; - return Consumer(builder: (context, provider, child) { - if (provider.isLoading) { - return const Center(child: CircularProgressIndicator()); - } + if (source == null) { + return Center(child: Text(loc.noWalletSelected)); + } - if (provider.error != null) { - return Center( - child: Text(loc.notificationError(provider.error.toString())), - ); - } + return switch (source.type) { + PaymentSourceType.wallet => Consumer( + builder: (context, provider, child) { + if (provider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } - final wallet = provider.selectedWallet; - if (wallet == null) { - return Center(child: Text(loc.noWalletSelected)); - } + if (provider.error != null) { + return Center( + child: Text(loc.notificationError(provider.error.toString())), + ); + } - return WalletTopUpContent( - wallet: wallet, - onBack: onBack, - ); - }); + final wallet = provider.selectedWallet; + if (wallet == null) { + return Center(child: Text(loc.noWalletSelected)); + } + + return WalletTopUpContent(wallet: wallet, onBack: onBack); + }, + ), + PaymentSourceType.ledger => Consumer( + builder: (context, provider, child) { + if (provider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (provider.error != null) { + return Center( + child: Text(loc.notificationError(provider.error.toString())), + ); + } + + final account = provider.accounts.firstWhereOrNull( + (item) => item.ledgerAccountRef == source.id, + ); + if (account == null) { + return Center(child: Text(loc.noWalletSelected)); + } + + return LedgerTopUpContent(account: account, onBack: onBack); + }, + ), + }; } }