diff --git a/frontend/pshared/lib/models/payment/methods/type.dart b/frontend/pshared/lib/models/payment/methods/type.dart index b05a3de..e169d7e 100644 --- a/frontend/pshared/lib/models/payment/methods/type.dart +++ b/frontend/pshared/lib/models/payment/methods/type.dart @@ -1,7 +1,11 @@ import 'package:pshared/models/describable.dart'; -import 'package:pshared/models/payment/type.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/russian_bank.dart'; +import 'package:pshared/models/payment/methods/wallet.dart'; +import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/permissions/bound.dart'; import 'package:pshared/models/permissions/bound/storable.dart'; import 'package:pshared/models/storable.dart'; @@ -28,6 +32,19 @@ class PaymentMethod implements PermissionBoundStorable, Describable { PaymentType get type => data.type; + T dataAs() { + final currentData = data; + if (currentData is T) return currentData; + throw StateError('Payment method data is ${currentData.runtimeType}, requested $T for type $type'); + } + T? dataAsOrNull() => data is T ? data as T : null; + + CardPaymentMethod? get cardData => dataAsOrNull(); + IbanPaymentMethod? get ibanData => dataAsOrNull(); + RussianBankAccountPaymentMethod? get bankAccountData => dataAsOrNull(); + WalletPaymentMethod? get walletData => dataAsOrNull(); + CryptoAddressPaymentMethod? get cryptoAddressData => dataAsOrNull(); + @override String get id => storable.id; @override diff --git a/frontend/pshared/lib/provider/recipient/pmethods.dart b/frontend/pshared/lib/provider/recipient/pmethods.dart index c28932b..a98bdcf 100644 --- a/frontend/pshared/lib/provider/recipient/pmethods.dart +++ b/frontend/pshared/lib/provider/recipient/pmethods.dart @@ -10,17 +10,19 @@ import 'package:pshared/service/recipient/pmethods.dart'; class PaymentMethodsProvider extends GenericProvider { late OrganizationsProvider _organizations; - late RecipientsProvider _recipients; PaymentMethodsProvider() : super(service: PaymentMethodService.basicService); List get methods => List.unmodifiable(items.toList()..sort((a, b) => a.storable.createdAt.compareTo(b.storable.createdAt))); void updateProviders(OrganizationsProvider organizations, RecipientsProvider recipients) { + if (recipients.currentObject != null) loadMethods(organizations, recipients.currentObject?.id); + } + + Future loadMethods(OrganizationsProvider organizations, String? recipientRef) async { _organizations = organizations; - _recipients = recipients; - if (_organizations.isOrganizationSet && (_recipients.currentObject != null)) { - load(_organizations.current.id, _recipients.currentObject!.id); + if (_organizations.isOrganizationSet && (recipientRef != null)) { + return load(_organizations.current.id, recipientRef); } } diff --git a/frontend/pweb/lib/pages/address_book/form/page.dart b/frontend/pweb/lib/pages/address_book/form/page.dart index 4133a69..ea1d0a2 100644 --- a/frontend/pweb/lib/pages/address_book/form/page.dart +++ b/frontend/pweb/lib/pages/address_book/form/page.dart @@ -2,16 +2,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/models/payment/methods/card.dart'; -import 'package:pshared/models/payment/methods/crypto_address.dart'; -import 'package:pshared/models/payment/methods/iban.dart'; -import 'package:pshared/models/payment/methods/russian_bank.dart'; -import 'package:pshared/models/payment/methods/wallet.dart'; import 'package:pshared/models/payment/type.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/status.dart'; import 'package:pshared/models/recipient/type.dart'; import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pweb/pages/address_book/form/view.dart'; import 'package:pweb/services/amplitude.dart'; @@ -36,6 +32,25 @@ class _AdressBookRecipientFormState extends State { RecipientType _type = RecipientType.internal; RecipientStatus _status = RecipientStatus.ready; final Map _methods = {}; + late PaymentMethodsProvider _methodsProvider; + + Future _loadMethods() async { + _methodsProvider = PaymentMethodsProvider()..addListener(_onProviderChanged); + await _methodsProvider.loadMethods( + context.read(), + widget.recipient?.id, + ); + + for (final m in _methodsProvider.methods) { + _methods[m.type] = switch (m.type) { + PaymentType.card => m.cardData, + PaymentType.iban => m.ibanData, + PaymentType.wallet => m.walletData, + PaymentType.bankAccount => m.bankAccountData, + PaymentType.cryptoAddress => m.cryptoAddressData, + }; + } + } @override void initState() { @@ -45,12 +60,7 @@ class _AdressBookRecipientFormState extends State { _emailCtrl = TextEditingController(text: r?.email ?? ''); _type = r?.type ?? RecipientType.internal; _status = r?.status ?? RecipientStatus.ready; - - if (r?.card != null) _methods[PaymentType.card] = r!.card; - if (r?.iban != null) _methods[PaymentType.iban] = r!.iban; - if (r?.wallet != null) _methods[PaymentType.wallet] = r!.wallet; - if (r?.bank != null) _methods[PaymentType.bankAccount] = r!.bank; - if (r?.cryptoAddress != null) _methods[PaymentType.cryptoAddress] = r!.cryptoAddress; + _loadMethods(); } //TODO: Change when registration is ready @@ -81,6 +91,15 @@ class _AdressBookRecipientFormState extends State { widget.onSaved?.call(recipient); } + @override + void dispose() { + _methodsProvider.removeListener(_onProviderChanged); + _methodsProvider.dispose(); + super.dispose(); + } + + void _onProviderChanged() => setState(() {}); + @override Widget build(BuildContext context) => FormView( formKey: _formKey, diff --git a/frontend/pweb/lib/pages/address_book/page/page.dart b/frontend/pweb/lib/pages/address_book/page/page.dart index 2ed2a90..c23664d 100644 --- a/frontend/pweb/lib/pages/address_book/page/page.dart +++ b/frontend/pweb/lib/pages/address_book/page/page.dart @@ -4,12 +4,12 @@ import 'package:provider/provider.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/models/recipient/filter.dart'; +import 'package:pshared/provider/recipient/provider.dart'; import 'package:pweb/pages/address_book/page/filter_button.dart'; import 'package:pweb/pages/address_book/page/header.dart'; import 'package:pweb/pages/address_book/page/list.dart'; import 'package:pweb/pages/address_book/page/search.dart'; -import 'package:pweb/providers/recipient.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -42,7 +42,7 @@ class _RecipientAddressBookPageState extends State { @override void initState() { super.initState(); - final provider = context.read(); + final provider = context.read(); _searchController = TextEditingController(text: provider.query); _searchFocusNode = FocusNode(); } @@ -54,7 +54,7 @@ class _RecipientAddressBookPageState extends State { super.dispose(); } - void _syncSearchField(RecipientProvider provider) { + void _syncSearchField(RecipientsProvider provider) { final query = provider.query; if (_searchController.text == query) return; @@ -68,7 +68,7 @@ class _RecipientAddressBookPageState extends State { Widget build(BuildContext context) { final loc = AppLocalizations.of(context)!; - final provider = context.watch(); + final provider = context.watch(); _syncSearchField(provider); if (provider.isLoading) { diff --git a/frontend/pweb/lib/pages/address_book/page/recipient/payment_row.dart b/frontend/pweb/lib/pages/address_book/page/recipient/payment_row.dart index a38626d..afc554b 100644 --- a/frontend/pweb/lib/pages/address_book/page/recipient/payment_row.dart +++ b/frontend/pweb/lib/pages/address_book/page/recipient/payment_row.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:pshared/models/payment/type.dart'; +import 'package:provider/provider.dart'; + import 'package:pshared/models/recipient/recipient.dart'; +import 'package:pshared/provider/organizations.dart'; +import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pweb/pages/address_book/page/recipient/info_row.dart'; +import 'package:pweb/utils/payment/label.dart'; -class RecipientPaymentRow extends StatelessWidget { +class RecipientPaymentRow extends StatefulWidget { final Recipient recipient; final double spacing; @@ -16,37 +20,43 @@ class RecipientPaymentRow extends StatelessWidget { this.spacing = 18 }); + @override + State createState() => _RecipientPaymentRowState(); +} + +class _RecipientPaymentRowState extends State { + late final PaymentMethodsProvider _methodsProvider; + + @override + void initState() { + super.initState(); + _methodsProvider = PaymentMethodsProvider() + ..addListener(_onProviderChanged) + ..loadMethods( + context.read(), + widget.recipient.id, + ); + } + + @override + void dispose() { + _methodsProvider.removeListener(_onProviderChanged); + _methodsProvider.dispose(); + super.dispose(); + } + + void _onProviderChanged() => setState(() {}); + @override Widget build(BuildContext context) { + if (!_methodsProvider.isReady) return const Center(child: CircularProgressIndicator()); + return Row( - spacing: spacing, - children: [ - if (recipient.bank?.accountNumber.isNotEmpty ?? false) - RecipientAddressBookInfoRow( - type: PaymentType.bankAccount, - value: recipient.bank!.accountNumber - ), - if (recipient.card?.pan.isNotEmpty ?? false) - RecipientAddressBookInfoRow( - type: PaymentType.card, - value: recipient.card!.pan - ), - if (recipient.iban?.iban.isNotEmpty ?? false) - RecipientAddressBookInfoRow( - type: PaymentType.iban, - value: recipient.iban!.iban - ), - if (recipient.wallet?.walletId.isNotEmpty ?? false) - RecipientAddressBookInfoRow( - type: PaymentType.wallet, - value: recipient.wallet!.walletId - ), - if (recipient.cryptoAddress?.address.isNotEmpty ?? false) - RecipientAddressBookInfoRow( - type: PaymentType.cryptoAddress, - value: recipient.cryptoAddress!.address, - ), - ], + spacing: widget.spacing, + children: _methodsProvider.methods.map((m) => RecipientAddressBookInfoRow( + type: m.type, + value: getPaymentTypeDescription(context, m), + )).toList(), ); } } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/item.dart b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/item.dart index fcbaac5..555bdbf 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/item.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/single/adress_book/long_list/item.dart @@ -5,7 +5,6 @@ import 'package:provider/provider.dart'; import 'package:pshared/models/recipient/recipient.dart'; import 'package:pshared/provider/organizations.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; -import 'package:pshared/provider/recipient/provider.dart'; import 'package:pweb/pages/dashboard/payouts/single/adress_book/avatar.dart'; import 'package:pweb/pages/dashboard/payouts/single/adress_book/long_list/info_row.dart'; @@ -37,13 +36,24 @@ class _RecipientItemState extends State { @override void initState() { super.initState(); - _methodsProvider = PaymentMethodsProvider(); - _methodsProvider.updateProviders( - context.read(), - context.read(), - ); + _methodsProvider = PaymentMethodsProvider() + ..addListener(_onProviderChanged) + ..loadMethods( + context.read(), + widget.recipient.id, + ); } + + @override + void dispose() { + _methodsProvider.removeListener(_onProviderChanged); + _methodsProvider.dispose(); + super.dispose(); + } + + void _onProviderChanged() => setState(() {}); + @override Widget build(BuildContext context) { if (!_methodsProvider.isReady) return const Center(child: CircularProgressIndicator()); @@ -78,7 +88,7 @@ class _RecipientItemState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: _methodsProvider.methods.map((m) => PaymentInfoRow( label: getPaymentTypeLabel(context, m.type), - value: _displayString(m), + value: getPaymentTypeDescription(context, m), )).toList(), ), ], diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart index f83bd79..fe907a8 100644 --- a/frontend/pweb/lib/pages/payment_methods/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/page.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:pshared/models/recipient/recipient.dart'; -import 'package:pshared/provider/recipient/pmethods.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pweb/providers/payment_flow_provider.dart'; @@ -48,8 +47,6 @@ class _PaymentPageState extends State { void _initializePaymentPage() { final pageSelector = context.read(); - final methodsProvider = context.read(); - final recipientProvider = context.read(); pageSelector.handleWalletAutoSelection(); diff --git a/frontend/pweb/lib/providers/page_selector.dart b/frontend/pweb/lib/providers/page_selector.dart index 3d1d4a9..21a4dca 100644 --- a/frontend/pweb/lib/providers/page_selector.dart +++ b/frontend/pweb/lib/providers/page_selector.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; -import 'package:logging/logging.dart'; import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/payment/type.dart'; @@ -16,8 +15,6 @@ import 'package:pweb/widgets/sidebar/destinations.dart'; class PageSelectorProvider extends ChangeNotifier { - static final _logger = Logger('provider.page_selector'); - PayoutDestination _selected = PayoutDestination.dashboard; PaymentType? _type; bool _cameFromRecipientList = false; @@ -65,7 +62,7 @@ class PageSelectorProvider extends ChangeNotifier { void goToAddRecipient() { AmplitudeService.recipientAddStarted(); - recipientProvider!.setCurrentObject(null); + recipientProvider.setCurrentObject(null); _selected = PayoutDestination.addrecipient; _cameFromRecipientList = false; notifyListeners(); @@ -114,7 +111,7 @@ class PageSelectorProvider extends ChangeNotifier { return null; } - return methodsProvider!.methods.firstWhereOrNull( + return methodsProvider.methods.firstWhereOrNull( (method) => method.type == PaymentType.wallet && (method.description?.contains(wallet.walletUserID) ?? false), ); diff --git a/frontend/pweb/lib/utils/payment/label.dart b/frontend/pweb/lib/utils/payment/label.dart index 38cacd7..e332737 100644 --- a/frontend/pweb/lib/utils/payment/label.dart +++ b/frontend/pweb/lib/utils/payment/label.dart @@ -1,7 +1,10 @@ import 'package:flutter/widgets.dart'; +import 'package:pshared/models/payment/methods/type.dart'; import 'package:pshared/models/payment/type.dart'; +import 'package:pweb/utils/payment/masking.dart'; + import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -15,3 +18,15 @@ String getPaymentTypeLabel(BuildContext context, PaymentType type) { PaymentType.cryptoAddress => l10n.paymentTypeCryptoAddress, }; } + +String? _displayString(PaymentMethod m) => switch (m.type) { + PaymentType.card => maskCardNumber(m.cardData?.pan), + PaymentType.bankAccount => m.bankAccountData?.accountNumber, + PaymentType.iban => m.ibanData?.iban, + PaymentType.wallet => m.walletData?.walletId, + PaymentType.cryptoAddress => m.cryptoAddressData?.address, +}; + +String getPaymentTypeDescription(BuildContext context, PaymentMethod m) { + return _displayString(m) ?? AppLocalizations.of(context)!.notSet; +} \ No newline at end of file diff --git a/frontend/pweb/lib/utils/payment/masking.dart b/frontend/pweb/lib/utils/payment/masking.dart new file mode 100644 index 0000000..2e4825b --- /dev/null +++ b/frontend/pweb/lib/utils/payment/masking.dart @@ -0,0 +1,25 @@ +import 'dart:math'; + + +/// Masks a card number, leaving only the last 4 digits visible. +/// Returns 'N/A' when the input is null or empty. +String? maskCardNumber(String? pan) { + if (pan == null || pan.isEmpty) return null; + + // Strip non-digits to avoid leaking formatting patterns. + final digits = pan.replaceAll(RegExp(r'\D'), ''); + if (digits.length <= 4) return digits; + + final last4 = digits.substring(digits.length - 4); + final maskedPrefix = '*' * (digits.length - 4); + final masked = '$maskedPrefix$last4'; + + // Group into blocks of 4 for readability. + final groups = []; + for (var i = 0; i < masked.length; i += 4) { + final end = min(i + 4, masked.length); + groups.add(masked.substring(i, end)); + } + + return groups.join(' '); +}