From e901ac3eb6ad4488df79c7a1d53389f7483417ff Mon Sep 17 00:00:00 2001 From: Arseni Date: Wed, 18 Feb 2026 18:15:38 +0300 Subject: [PATCH] verification before payment and email fixes --- api/payments/storage/mongo/store/payments.go | 4 +- .../lib/provider/payment/payments.dart | 136 ++++++++++--- .../lib/provider/payout_verification.dart | 103 ++++++++++ .../pshared/lib/service/verification.dart | 59 ++++++ .../pweb/lib/app/router/payout_shell.dart | 11 ++ frontend/pweb/lib/controllers/email.dart | 44 +++++ .../lib/controllers/multiple_payouts.dart | 41 +++- .../pweb/lib/controllers/payment_details.dart | 77 ++++++++ .../lib/controllers/payout_verification.dart | 184 ++++++++++++++++++ .../pweb/lib/controllers/recent_payments.dart | 15 ++ .../lib/controllers/report_operations.dart | 28 +++ frontend/pweb/lib/l10n/en.arb | 7 + frontend/pweb/lib/l10n/ru.arb | 7 + frontend/pweb/lib/models/load_more_state.dart | 5 + frontend/pweb/lib/pages/2fa/page.dart | 10 +- frontend/pweb/lib/pages/2fa/prompt.dart | 15 +- frontend/pweb/lib/pages/2fa/resend.dart | 27 ++- .../dashboard/payouts/multiple/actions.dart | 13 +- .../multiple/panels/source_quote/widget.dart | 43 ++-- frontend/pweb/lib/pages/login/form.dart | 17 +- .../pweb/lib/pages/payment_methods/page.dart | 18 ++ .../payment_methods/payment_page/body.dart | 7 + .../payment_methods/payment_page/page.dart | 21 +- .../lib/pages/payout_verification/page.dart | 64 ++++++ .../pweb/lib/pages/report/cards/list.dart | 47 ++++- .../pweb/lib/pages/report/details/page.dart | 51 +++-- frontend/pweb/lib/pages/report/page.dart | 6 + .../lib/pages/signup/form/controllers.dart | 4 +- .../pweb/lib/pages/signup/form/email.dart | 32 --- .../pweb/lib/pages/signup/form/feilds.dart | 4 +- .../pweb/lib/providers/multiple_payouts.dart | 10 - .../payment/payout_verification_flow.dart | 50 +++++ frontend/pweb/lib/widgets/cooldown_hint.dart | 25 +++ frontend/pweb/lib/widgets/username.dart | 29 +-- frontend/pweb/pubspec.yaml | 1 - 35 files changed, 1023 insertions(+), 192 deletions(-) create mode 100644 frontend/pshared/lib/provider/payout_verification.dart create mode 100644 frontend/pweb/lib/controllers/email.dart create mode 100644 frontend/pweb/lib/controllers/payment_details.dart create mode 100644 frontend/pweb/lib/controllers/payout_verification.dart create mode 100644 frontend/pweb/lib/models/load_more_state.dart create mode 100644 frontend/pweb/lib/pages/payout_verification/page.dart delete mode 100644 frontend/pweb/lib/pages/signup/form/email.dart create mode 100644 frontend/pweb/lib/utils/payment/payout_verification_flow.dart create mode 100644 frontend/pweb/lib/widgets/cooldown_hint.dart diff --git a/api/payments/storage/mongo/store/payments.go b/api/payments/storage/mongo/store/payments.go index 81385c1a..f78dedc4 100644 --- a/api/payments/storage/mongo/store/payments.go +++ b/api/payments/storage/mongo/store/payments.go @@ -216,7 +216,7 @@ func (p *Payments) List(ctx context.Context, filter *model.PaymentFilter) (*mode if cursor := strings.TrimSpace(filter.Cursor); cursor != "" { if oid, err := bson.ObjectIDFromHex(cursor); err == nil { - query = query.Comparison(repository.IDField(), builder.Gt, oid) + query = query.Comparison(repository.IDField(), builder.Lt, oid) } else { p.logger.Warn("Ignoring invalid payments cursor", zap.String("cursor", cursor), zap.Error(err)) } @@ -224,7 +224,7 @@ func (p *Payments) List(ctx context.Context, filter *model.PaymentFilter) (*mode limit := sanitizePaymentLimit(filter.Limit) fetchLimit := limit + 1 - query = query.Sort(repository.IDField(), true).Limit(&fetchLimit) + query = query.Sort(repository.IDField(), false).Limit(&fetchLimit) payments := make([]*model.Payment, 0, fetchLimit) decoder := func(cur *mongo.Cursor) error { diff --git a/frontend/pshared/lib/provider/payment/payments.dart b/frontend/pshared/lib/provider/payment/payments.dart index 234d35fa..5ba2a3be 100644 --- a/frontend/pshared/lib/provider/payment/payments.dart +++ b/frontend/pshared/lib/provider/payment/payments.dart @@ -17,7 +17,9 @@ class PaymentsProvider with ChangeNotifier { bool _isLoaded = false; bool _isLoadingMore = false; String? _nextCursor; - + Timer? _autoRefreshTimer; + int _autoRefreshRefs = 0; + Duration _autoRefreshInterval = const Duration(seconds: 15); int? _limit; String? _sourceRef; String? _destinationRef; @@ -35,6 +37,23 @@ class PaymentsProvider with ChangeNotifier { String? get nextCursor => _nextCursor; bool get canLoadMore => _nextCursor != null && _nextCursor!.isNotEmpty; + void beginAutoRefresh({Duration interval = const Duration(seconds: 15)}) { + _autoRefreshRefs += 1; + if (interval < _autoRefreshInterval) { + _autoRefreshInterval = interval; + _restartAutoRefreshTimer(); + } + _ensureAutoRefreshTimer(); + } + + void endAutoRefresh() { + if (_autoRefreshRefs == 0) return; + _autoRefreshRefs -= 1; + if (_autoRefreshRefs == 0) { + _stopAutoRefreshTimer(); + } + } + void update(OrganizationsProvider organizations) { _organizations = organizations; if (!organizations.isOrganizationSet) { @@ -42,6 +61,10 @@ class PaymentsProvider with ChangeNotifier { return; } + if (_autoRefreshRefs > 0) { + _ensureAutoRefreshTimer(); + } + final orgRef = organizations.current.id; if (_loadedOrganizationRef != orgRef) { _loadedOrganizationRef = orgRef; @@ -54,6 +77,40 @@ class PaymentsProvider with ChangeNotifier { String? sourceRef, String? destinationRef, List? states, + }) async { + await _refresh( + limit: limit, + sourceRef: sourceRef, + destinationRef: destinationRef, + states: states, + showLoading: true, + updateError: true, + ); + } + + Future refreshSilently({ + int? limit, + String? sourceRef, + String? destinationRef, + List? states, + }) async { + await _refresh( + limit: limit, + sourceRef: sourceRef, + destinationRef: destinationRef, + states: states, + showLoading: false, + updateError: false, + ); + } + + Future _refresh({ + int? limit, + String? sourceRef, + String? destinationRef, + List? states, + required bool showLoading, + required bool updateError, }) async { final org = _organizations; if (org == null || !org.isOrganizationSet) return; @@ -67,7 +124,9 @@ class PaymentsProvider with ChangeNotifier { final seq = ++_opSeq; - _applyResource(_resource.copyWith(isLoading: true, error: null), notify: true); + if (showLoading) { + _applyResource(_resource.copyWith(isLoading: true, error: null), notify: true); + } try { final page = await PaymentService.listPage( @@ -84,16 +143,26 @@ class PaymentsProvider with ChangeNotifier { _isLoaded = true; _nextCursor = _normalize(page.nextCursor); _applyResource( - Resource(data: page.items, isLoading: false, error: null), + Resource( + data: page.items, + isLoading: false, + error: null, + ), notify: true, ); } catch (e) { if (seq != _opSeq) return; - - _applyResource( - _resource.copyWith(isLoading: false, error: toException(e)), - notify: true, - ); + if (updateError) { + _applyResource( + _resource.copyWith(isLoading: false, error: toException(e)), + notify: true, + ); + } else if (showLoading) { + _applyResource( + _resource.copyWith(isLoading: false), + notify: true, + ); + } } } @@ -155,31 +224,10 @@ class PaymentsProvider with ChangeNotifier { _destinationRef = null; _states = null; _resource = Resource(data: []); + _pauseAutoRefreshTimer(); notifyListeners(); } - void addPayments(List items, {bool prepend = true}) { - if (items.isEmpty) return; - final current = List.from(payments); - final existingRefs = {}; - for (final payment in current) { - final ref = payment.paymentRef; - if (ref != null && ref.isNotEmpty) { - existingRefs.add(ref); - } - } - - final newItems = items.where((payment) { - final ref = payment.paymentRef; - if (ref == null || ref.isEmpty) return true; - return !existingRefs.contains(ref); - }).toList(); - - if (newItems.isEmpty) return; - final combined = prepend ? [...newItems, ...current] : [...current, ...newItems]; - _applyResource(_resource.copyWith(data: combined, error: null), notify: true); - } - void _applyResource(Resource> newResource, {required bool notify}) { _resource = newResource; if (notify) notifyListeners(); @@ -200,4 +248,32 @@ class PaymentsProvider with ChangeNotifier { if (normalized.isEmpty) return null; return normalized; } + + + void _ensureAutoRefreshTimer() { + if (_autoRefreshTimer != null) return; + _autoRefreshTimer = Timer.periodic(_autoRefreshInterval, (_) { + if (_resource.isLoading || _isLoadingMore) return; + unawaited(refreshSilently()); + }); + } + + void _restartAutoRefreshTimer() { + if (_autoRefreshTimer == null) return; + _autoRefreshTimer?.cancel(); + _autoRefreshTimer = null; + _ensureAutoRefreshTimer(); + } + + void _stopAutoRefreshTimer() { + _autoRefreshTimer?.cancel(); + _autoRefreshTimer = null; + _autoRefreshRefs = 0; + _autoRefreshInterval = const Duration(seconds: 15); + } + + void _pauseAutoRefreshTimer() { + _autoRefreshTimer?.cancel(); + _autoRefreshTimer = null; + } } diff --git a/frontend/pshared/lib/provider/payout_verification.dart b/frontend/pshared/lib/provider/payout_verification.dart new file mode 100644 index 00000000..71d40378 --- /dev/null +++ b/frontend/pshared/lib/provider/payout_verification.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/api/responses/verification/response.dart'; +import 'package:pshared/provider/resource.dart'; +import 'package:pshared/service/verification.dart'; +import 'package:pshared/utils/exception.dart'; + + +class PayoutVerificationProvider extends ChangeNotifier { + Resource _resource = Resource(data: null); + DateTime? _cooldownUntil; + + VerificationResponse? get response => _resource.data; + bool get isLoading => _resource.isLoading; + Exception? get error => _resource.error; + String get target => _resource.data?.target ?? ''; + String? get idempotencyKey => _resource.data?.idempotencyKey; + DateTime? get cooldownUntil => _cooldownUntil; + + Future requestCode() async { + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final response = await VerificationService.requestPayoutCode(); + _cooldownUntil = _resolveCooldownUntil(response); + _setResource(_resource.copyWith( + data: response, + isLoading: false, + error: null, + )); + return response; + } catch (e) { + _setResource(_resource.copyWith( + isLoading: false, + error: toException(e), + )); + rethrow; + } + } + + Future resendCode() async { + final currentKey = _resource.data?.idempotencyKey; + if (currentKey == null || currentKey.isEmpty) { + throw StateError('Payout verification is not initialized'); + } + + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + final response = await VerificationService.resendPayoutCode( + idempotencyKey: currentKey, + ); + _cooldownUntil = _resolveCooldownUntil(response); + _setResource(_resource.copyWith( + data: response, + isLoading: false, + error: null, + )); + return response; + } catch (e) { + _setResource(_resource.copyWith( + isLoading: false, + error: toException(e), + )); + rethrow; + } + } + + Future confirmCode(String code) async { + final currentKey = _resource.data?.idempotencyKey; + if (currentKey == null || currentKey.isEmpty) { + throw StateError('Payout verification is not initialized'); + } + + _setResource(_resource.copyWith(isLoading: true, error: null)); + try { + await VerificationService.confirmPayoutCode( + code: code.trim(), + idempotencyKey: currentKey, + ); + _setResource(_resource.copyWith(isLoading: false, error: null)); + } catch (e) { + _setResource(_resource.copyWith( + isLoading: false, + error: toException(e), + )); + rethrow; + } + } + + void reset() { + _cooldownUntil = null; + _setResource(Resource(data: null)); + } + + DateTime? _resolveCooldownUntil(VerificationResponse response) { + if (response.cooldownSeconds <= 0) return null; + return DateTime.now().add(response.cooldownDuration); + } + + void _setResource(Resource resource) { + _resource = resource; + notifyListeners(); + } +} diff --git a/frontend/pshared/lib/service/verification.dart b/frontend/pshared/lib/service/verification.dart index 99cd3f0a..99febb82 100644 --- a/frontend/pshared/lib/service/verification.dart +++ b/frontend/pshared/lib/service/verification.dart @@ -2,6 +2,7 @@ import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; +import 'package:pshared/api/requests/tokens/session_identifier.dart'; import 'package:pshared/api/requests/verification/login.dart'; import 'package:pshared/api/responses/login.dart'; import 'package:pshared/api/responses/verification/response.dart'; @@ -9,6 +10,8 @@ import 'package:pshared/data/mapper/account/account.dart'; import 'package:pshared/data/mapper/session_identifier.dart'; import 'package:pshared/models/account/account.dart'; import 'package:pshared/models/auth/pending_login.dart'; +import 'package:pshared/models/verification/purpose.dart'; +import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/authorization/storage.dart'; import 'package:pshared/service/services.dart'; import 'package:pshared/utils/http/requests.dart'; @@ -69,4 +72,60 @@ class VerificationService { await AuthorizationStorage.updateRefreshToken(loginResponse.refreshToken); return loginResponse.account.toDomain(); } + + static Future requestPayoutCode({ + String? target, + String? idempotencyKey, + }) async { + _logger.fine('Requesting payout confirmation code'); + final response = await AuthorizationService.getPOSTResponse( + _objectType, + '', + LoginVerificationRequest( + purpose: VerificationPurpose.payout, + target: target, + idempotencyKey: idempotencyKey ?? Uuid().v4(), + ).toJson(), + ); + return VerificationResponse.fromJson(response); + } + + static Future resendPayoutCode({ + required String idempotencyKey, + String? target, + }) async { + _logger.fine('Resending payout confirmation code'); + final response = await AuthorizationService.getPOSTResponse( + _objectType, + '/resend', + LoginVerificationRequest( + purpose: VerificationPurpose.payout, + target: target, + idempotencyKey: idempotencyKey, + ).toJson(), + ); + return VerificationResponse.fromJson(response); + } + + static Future confirmPayoutCode({ + required String code, + required String idempotencyKey, + String? target, + }) async { + _logger.fine('Confirming payout code'); + await AuthorizationService.getPOSTResponse( + _objectType, + '/verify', + LoginCodeVerifyicationRequest( + purpose: VerificationPurpose.payout, + target: target, + idempotencyKey: idempotencyKey, + code: code, + sessionIdentifier: const SessionIdentifierDTO( + clientId: '', + deviceId: '', + ), + ).toJson(), + ); + } } diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 175ed265..e52f6828 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -15,6 +15,7 @@ import 'package:pshared/provider/payment/multiple/quotation.dart'; import 'package:pshared/provider/payment/payments.dart'; import 'package:pshared/provider/payment/provider.dart'; import 'package:pshared/provider/payment/quotation/quotation.dart'; +import 'package:pshared/provider/payout_verification.dart'; import 'package:pshared/provider/recipient/provider.dart'; import 'package:pshared/provider/recipient/methods_cache.dart'; import 'package:pshared/provider/recipient/pmethods.dart'; @@ -23,6 +24,7 @@ import 'package:pweb/app/router/pages.dart'; import 'package:pweb/app/router/payout_routes.dart'; import 'package:pweb/controllers/multiple_payouts.dart'; import 'package:pweb/controllers/payment_page.dart'; +import 'package:pweb/controllers/payout_verification.dart'; import 'package:pweb/providers/multiple_payouts.dart'; import 'package:pweb/controllers/multi_quotation.dart'; import 'package:pweb/providers/quotation/quotation.dart'; @@ -122,6 +124,15 @@ RouteBase payoutShellRoute() => ShellRoute( update: (context, organization, quotation, provider) => provider!..update(organization, quotation), ), + ChangeNotifierProvider(create: (_) => PayoutVerificationProvider()), + ChangeNotifierProxyProvider< + PayoutVerificationProvider, + PayoutVerificationController + >( + create: (_) => PayoutVerificationController(), + update: (context, verification, controller) => + controller!..update(verification), + ), ChangeNotifierProxyProvider4< PaymentProvider, QuotationProvider, diff --git a/frontend/pweb/lib/controllers/email.dart b/frontend/pweb/lib/controllers/email.dart new file mode 100644 index 00000000..6a597132 --- /dev/null +++ b/frontend/pweb/lib/controllers/email.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:email_validator/email_validator.dart'; + + +class EmailFieldController { + final TextEditingController textController; + final ValueNotifier isValid; + + EmailFieldController({ + TextEditingController? controller, + }) : textController = controller ?? TextEditingController(), + isValid = ValueNotifier(false); + + String get text => textController.text; + + void setText(String value) { + textController.text = value; + onChanged(value); + } + + bool _isValidEmail(String? value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return false; + } + return EmailValidator.validate(trimmed); + } + + String? validate(String? value, String invalidMessage) { + final result = _isValidEmail(value) ? null : invalidMessage; + isValid.value = result == null; + return result; + } + + void onChanged(String? value) { + isValid.value = _isValidEmail(value); + } + + void dispose() { + textController.dispose(); + isValid.dispose(); + } +} diff --git a/frontend/pweb/lib/controllers/multiple_payouts.dart b/frontend/pweb/lib/controllers/multiple_payouts.dart index 594f5091..b1b97b6d 100644 --- a/frontend/pweb/lib/controllers/multiple_payouts.dart +++ b/frontend/pweb/lib/controllers/multiple_payouts.dart @@ -17,6 +17,7 @@ class MultiplePayoutsController extends ChangeNotifier { MultiplePayoutsProvider? _provider; WalletsController? _wallets; _PickState _pickState = _PickState.idle; + Exception? _uiError; MultiplePayoutsController({ required CsvInputService csvInput, @@ -46,7 +47,7 @@ class MultiplePayoutsController extends ChangeNotifier { String? get selectedFileName => _provider?.selectedFileName; List get rows => _provider?.rows ?? const []; int get sentCount => _provider?.sentCount ?? 0; - Exception? get error => _provider?.error; + Exception? get error => _uiError ?? _provider?.error; bool get isQuoting => _provider?.isQuoting ?? false; bool get isSending => _provider?.isSending ?? false; @@ -71,15 +72,19 @@ class MultiplePayoutsController extends ChangeNotifier { Future pickAndQuote() async { if (_pickState == _PickState.picking) return; final provider = _provider; - if (provider == null) return; + if (provider == null) { + _setUiError(StateError('Multiple payouts provider is not ready')); + return; + } + _clearUiError(); _pickState = _PickState.picking; try { final picked = await _csvInput.pickCsv(); if (picked == null) return; final wallet = _selectedWallet; if (wallet == null) { - provider.setError(StateError('Select source wallet first')); + _setUiError(StateError('Select source wallet first')); return; } await provider.quoteFromCsv( @@ -88,7 +93,7 @@ class MultiplePayoutsController extends ChangeNotifier { sourceWallet: wallet, ); } catch (e) { - provider.setError(e); + _setUiError(e); } finally { _pickState = _PickState.idle; } @@ -98,10 +103,16 @@ class MultiplePayoutsController extends ChangeNotifier { return _provider?.send() ?? const []; } - Future sendAndStorePayments() async { - final payments = - await _provider?.sendAndStorePayments() ?? const []; - final hasError = _provider?.error != null; + Future sendAndGetOutcome() async { + _clearUiError(); + final provider = _provider; + if (provider == null) { + _setUiError(StateError('Multiple payouts provider is not ready')); + return MultiplePayoutSendOutcome.failure; + } + + final payments = await provider.send(); + final hasError = provider.error != null; if (hasError || payments.isEmpty) { return MultiplePayoutSendOutcome.failure; } @@ -110,6 +121,7 @@ class MultiplePayoutsController extends ChangeNotifier { void removeUploadedFile() { _provider?.removeUploadedFile(); + _clearUiError(notify: false); } void _onProviderChanged() { @@ -122,6 +134,19 @@ class MultiplePayoutsController extends ChangeNotifier { Wallet? get _selectedWallet => _wallets?.selectedWallet; + void _setUiError(Object error) { + _uiError = error is Exception ? error : Exception(error.toString()); + notifyListeners(); + } + + void _clearUiError({bool notify = true}) { + if (_uiError == null) return; + _uiError = null; + if (notify) { + notifyListeners(); + } + } + @override void dispose() { _provider?.removeListener(_onProviderChanged); diff --git a/frontend/pweb/lib/controllers/payment_details.dart b/frontend/pweb/lib/controllers/payment_details.dart new file mode 100644 index 00000000..018d08bd --- /dev/null +++ b/frontend/pweb/lib/controllers/payment_details.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:pshared/models/payment/payment.dart'; +import 'package:pshared/models/payment/status.dart'; +import 'package:pshared/provider/payment/payments.dart'; + +import 'package:pweb/utils/report/payment_mapper.dart'; + + +class PaymentDetailsController extends ChangeNotifier { + PaymentDetailsController({required String paymentId}) + : _paymentId = paymentId; + + PaymentsProvider? _payments; + String _paymentId; + Payment? _payment; + + String get paymentId => _paymentId; + Payment? get payment => _payment; + bool get isLoading => _payments?.isLoading ?? false; + Exception? get error => _payments?.error; + + bool get canDownload { + final current = _payment; + if (current == null) return false; + final status = statusFromPayment(current); + final paymentRef = current.paymentRef ?? ''; + return status == OperationStatus.success && + paymentRef.trim().isNotEmpty; + } + + void update(PaymentsProvider provider, String paymentId) { + if (_paymentId != paymentId) { + _paymentId = paymentId; + } + + if (!identical(_payments, provider)) { + _payments?.endAutoRefresh(); + _payments = provider; + _payments?.beginAutoRefresh(); + if (provider.isReady || provider.isLoading) { + unawaited(_payments?.refreshSilently()); + } else { + unawaited(_payments?.refresh()); + } + } + + _rebuild(); + } + + Future refresh() async { + await _payments?.refresh(); + } + + void _rebuild() { + _payment = _findPayment(_payments?.payments ?? const [], _paymentId); + notifyListeners(); + } + + Payment? _findPayment(List payments, String paymentId) { + final trimmed = paymentId.trim(); + if (trimmed.isEmpty) return null; + for (final payment in payments) { + if (payment.paymentRef == trimmed) return payment; + if (payment.idempotencyKey == trimmed) return payment; + } + return null; + } + + @override + void dispose() { + _payments?.endAutoRefresh(); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/controllers/payout_verification.dart b/frontend/pweb/lib/controllers/payout_verification.dart new file mode 100644 index 00000000..5f92449f --- /dev/null +++ b/frontend/pweb/lib/controllers/payout_verification.dart @@ -0,0 +1,184 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:pshared/provider/payout_verification.dart'; + +import 'package:pweb/models/flow_status.dart'; + + +class PayoutVerificationController extends ChangeNotifier { + PayoutVerificationProvider? _provider; + + FlowStatus _status = FlowStatus.idle; + Object? _error; + Timer? _cooldownTimer; + int _cooldownRemainingSeconds = 0; + DateTime? _cooldownUntil; + + FlowStatus get status => _status; + bool get isSubmitting => _status == FlowStatus.submitting; + bool get isResending => _status == FlowStatus.resending; + bool get hasError => _status == FlowStatus.error; + bool get verificationSuccess => _status == FlowStatus.success; + Object? get error => _error; + String get target => _provider?.target ?? ''; + int get cooldownRemainingSeconds => _cooldownRemainingSeconds; + bool get isCooldownActive => _cooldownRemainingSeconds > 0; + + void update(PayoutVerificationProvider provider) { + if (identical(_provider, provider)) return; + _provider?.removeListener(_onProviderChanged); + _provider = provider; + _provider?.addListener(_onProviderChanged); + _syncCooldown(provider.cooldownUntil); + } + + Future requestCode() async { + final provider = _provider; + if (provider == null) { + throw StateError('Payout verification provider is not ready'); + } + _error = null; + _setStatus(FlowStatus.submitting); + try { + await provider.requestCode(); + _setStatus(FlowStatus.idle); + } catch (e) { + _error = e; + _setStatus(FlowStatus.error); + rethrow; + } + } + + Future submitCode(String code) async { + final provider = _provider; + if (provider == null) { + throw StateError('Payout verification provider is not ready'); + } + + _error = null; + _setStatus(FlowStatus.submitting); + + try { + await provider.confirmCode(code); + _setStatus(FlowStatus.success); + } catch (e) { + _error = e; + _setStatus(FlowStatus.error); + } + } + + Future resendCode() async { + final provider = _provider; + if (provider == null) { + throw StateError('Payout verification provider is not ready'); + } + if (isResending || isCooldownActive) return; + + _error = null; + _setStatus(FlowStatus.resending); + + try { + await provider.resendCode(); + _setStatus(FlowStatus.idle); + } catch (e) { + _error = e; + _setStatus(FlowStatus.error); + } + } + + void reset() { + _error = null; + _setStatus(FlowStatus.idle); + _stopCooldown(); + _provider?.reset(); + } + + void resetStatus() { + _error = null; + _setStatus(FlowStatus.idle); + } + + void _onProviderChanged() { + _syncCooldown(_provider?.cooldownUntil); + notifyListeners(); + } + + void _syncCooldown(DateTime? until) { + if (until == null) { + _stopCooldown(notify: _cooldownRemainingSeconds != 0); + return; + } + + if (!_isCooldownActive(until) && _cooldownRemainingSeconds != 0) { + _stopCooldown(notify: true); + return; + } + + if (_cooldownUntil == null || _cooldownUntil != until) { + _startCooldownUntil(until); + } + } + + void _startCooldownUntil(DateTime until) { + _cooldownTimer?.cancel(); + _cooldownUntil = until; + _cooldownRemainingSeconds = _cooldownRemaining(); + + if (_cooldownRemainingSeconds <= 0) { + _cooldownTimer = null; + _cooldownUntil = null; + notifyListeners(); + return; + } + + _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + final remaining = _cooldownRemaining(); + if (remaining <= 0) { + _stopCooldown(notify: true); + return; + } + if (remaining != _cooldownRemainingSeconds) { + _cooldownRemainingSeconds = remaining; + notifyListeners(); + } + }); + + notifyListeners(); + } + + bool _isCooldownActive(DateTime until) => until.isAfter(DateTime.now()); + + int _cooldownRemaining() { + final until = _cooldownUntil; + if (until == null) return 0; + final remaining = until.difference(DateTime.now()).inSeconds; + return remaining < 0 ? 0 : remaining; + } + + void _stopCooldown({bool notify = false}) { + _cooldownTimer?.cancel(); + _cooldownTimer = null; + final hadCooldown = _cooldownRemainingSeconds != 0; + _cooldownRemainingSeconds = 0; + _cooldownUntil = null; + + if (notify && hadCooldown) { + notifyListeners(); + } + } + + void _setStatus(FlowStatus status) { + if (_status == status) return; + _status = status; + notifyListeners(); + } + + @override + void dispose() { + _provider?.removeListener(_onProviderChanged); + _stopCooldown(); + super.dispose(); + } +} diff --git a/frontend/pweb/lib/controllers/recent_payments.dart b/frontend/pweb/lib/controllers/recent_payments.dart index 72497a65..4435e04c 100644 --- a/frontend/pweb/lib/controllers/recent_payments.dart +++ b/frontend/pweb/lib/controllers/recent_payments.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:pshared/models/payment/operation.dart'; @@ -17,7 +19,14 @@ class RecentPaymentsController extends ChangeNotifier { void update(PaymentsProvider provider) { if (!identical(_payments, provider)) { + _payments?.endAutoRefresh(); _payments = provider; + _payments?.beginAutoRefresh(); + if (provider.isReady || provider.isLoading) { + unawaited(_payments?.refreshSilently()); + } else { + unawaited(_payments?.refresh()); + } } _rebuild(); } @@ -30,4 +39,10 @@ class RecentPaymentsController extends ChangeNotifier { notifyListeners(); } + @override + void dispose() { + _payments?.endAutoRefresh(); + super.dispose(); + } + } diff --git a/frontend/pweb/lib/controllers/report_operations.dart b/frontend/pweb/lib/controllers/report_operations.dart index 8da4fe84..7b723549 100644 --- a/frontend/pweb/lib/controllers/report_operations.dart +++ b/frontend/pweb/lib/controllers/report_operations.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:collection'; import 'package:flutter/material.dart'; @@ -6,6 +7,7 @@ import 'package:pshared/models/payment/operation.dart'; import 'package:pshared/models/payment/status.dart'; import 'package:pshared/provider/payment/payments.dart'; +import 'package:pweb/models/load_more_state.dart'; import 'package:pweb/utils/report/operations.dart'; import 'package:pweb/utils/report/payment_mapper.dart'; @@ -25,10 +27,26 @@ class ReportOperationsController extends ChangeNotifier { bool get isLoading => _payments?.isLoading ?? false; Exception? get error => _payments?.error; + LoadMoreState get loadMoreState { + if (_payments?.isLoadingMore ?? false) { + return LoadMoreState.loading; + } + if (_payments?.canLoadMore ?? false) { + return LoadMoreState.available; + } + return LoadMoreState.hidden; + } void update(PaymentsProvider provider) { if (!identical(_payments, provider)) { + _payments?.endAutoRefresh(); _payments = provider; + _payments?.beginAutoRefresh(); + if (provider.isReady || provider.isLoading) { + unawaited(_payments?.refreshSilently()); + } else { + unawaited(_payments?.refresh()); + } } _rebuildOperations(); } @@ -59,6 +77,10 @@ class ReportOperationsController extends ChangeNotifier { await _payments?.refresh(); } + Future loadMore() async { + await _payments?.loadMore(); + } + void _rebuildOperations() { final items = _payments?.payments ?? const []; _operations = items.map(mapPaymentToOperation).toList(); @@ -101,4 +123,10 @@ class ReportOperationsController extends ChangeNotifier { left.end.isAtSameMomentAs(right.end); } + @override + void dispose() { + _payments?.endAutoRefresh(); + super.dispose(); + } + } diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index f275a79b..c4bbd9e1 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -129,6 +129,13 @@ "twoFactorResend": "Didn’t receive a code? Resend", "twoFactorTitle": "Two-Factor Authentication", "twoFactorError": "Invalid code. Please try again.", + "payoutCooldown": "You can send again in {time}", + "@payoutCooldown": { + "placeholders": { + "time": {} + } + }, + "loadMore": "Load more", "payoutNavDashboard": "Dashboard", "payoutNavSendPayout": "Send payout", "payoutNavRecipients": "Recipients", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index a94bcb30..abb6063f 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -129,6 +129,13 @@ "twoFactorResend": "Не получили код? Отправить снова", "twoFactorTitle": "Двухфакторная аутентификация", "twoFactorError": "Неверный код. Пожалуйста, попробуйте снова.", + "payoutCooldown": "Можно отправить через {time}", + "@payoutCooldown": { + "placeholders": { + "time": {} + } + }, + "loadMore": "Показать еще", "payoutNavDashboard": "Дашборд", "payoutNavSendPayout": "Отправить выплату", "payoutNavRecipients": "Получатели", diff --git a/frontend/pweb/lib/models/load_more_state.dart b/frontend/pweb/lib/models/load_more_state.dart new file mode 100644 index 00000000..8abf64a5 --- /dev/null +++ b/frontend/pweb/lib/models/load_more_state.dart @@ -0,0 +1,5 @@ +enum LoadMoreState { + hidden, + available, + loading, +} diff --git a/frontend/pweb/lib/pages/2fa/page.dart b/frontend/pweb/lib/pages/2fa/page.dart index f4be4a06..cc5ce240 100644 --- a/frontend/pweb/lib/pages/2fa/page.dart +++ b/frontend/pweb/lib/pages/2fa/page.dart @@ -23,6 +23,7 @@ class TwoFactorCodePage extends StatelessWidget { Widget build(BuildContext context) { return Consumer( builder: (context, provider, child) { + final email = provider.pendingLogin?.target ?? ''; if (provider.verificationSuccess) { WidgetsBinding.instance.addPostFrameCallback((_) { onVerificationSuccess(); @@ -36,7 +37,7 @@ class TwoFactorCodePage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - const TwoFactorPromptText(), + TwoFactorPromptText(email: email), const SizedBox(height: 32), TwoFactorCodeInput( onCompleted: (code) => provider.submitCode(code), @@ -45,7 +46,12 @@ class TwoFactorCodePage extends StatelessWidget { if (provider.isSubmitting) const Center(child: CircularProgressIndicator()) else - const ResendCodeButton(), + ResendCodeButton( + onPressed: provider.resendCode, + isCooldownActive: provider.isCooldownActive, + isResending: provider.isResending, + cooldownRemainingSeconds: provider.cooldownRemainingSeconds, + ), if (provider.hasError) ...[ const SizedBox(height: 12), ErrorMessage(error: AppLocalizations.of(context)!.twoFactorError), diff --git a/frontend/pweb/lib/pages/2fa/prompt.dart b/frontend/pweb/lib/pages/2fa/prompt.dart index b0acf931..42ddf882 100644 --- a/frontend/pweb/lib/pages/2fa/prompt.dart +++ b/frontend/pweb/lib/pages/2fa/prompt.dart @@ -1,20 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:pweb/providers/two_factor.dart'; - import 'package:pweb/generated/i18n/app_localizations.dart'; class TwoFactorPromptText extends StatelessWidget { - const TwoFactorPromptText({super.key}); + final String email; + + const TwoFactorPromptText({ + super.key, + required this.email, + }); @override Widget build(BuildContext context) => Text( - AppLocalizations.of(context)!.twoFactorPrompt( - context.watch().pendingLogin?.target ?? '', - ), + AppLocalizations.of(context)!.twoFactorPrompt(email), style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center, ); diff --git a/frontend/pweb/lib/pages/2fa/resend.dart b/frontend/pweb/lib/pages/2fa/resend.dart index 11047e89..03138a1d 100644 --- a/frontend/pweb/lib/pages/2fa/resend.dart +++ b/frontend/pweb/lib/pages/2fa/resend.dart @@ -1,8 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:pweb/providers/two_factor.dart'; import 'package:pweb/utils/cooldown_format.dart'; import 'package:pweb/widgets/resend_link.dart'; @@ -10,23 +7,33 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class ResendCodeButton extends StatelessWidget { - const ResendCodeButton({super.key}); + final VoidCallback onPressed; + final bool isCooldownActive; + final bool isResending; + final int cooldownRemainingSeconds; + + const ResendCodeButton({ + super.key, + required this.onPressed, + required this.isCooldownActive, + required this.isResending, + required this.cooldownRemainingSeconds, + }); @override Widget build(BuildContext context) { final localizations = AppLocalizations.of(context)!; - final provider = context.watch(); - final isDisabled = provider.isCooldownActive || provider.isResending; + final isDisabled = isCooldownActive || isResending; - final label = provider.isCooldownActive - ? '${localizations.twoFactorResend} (${formatCooldownSeconds(provider.cooldownRemainingSeconds)})' + final label = isCooldownActive + ? '${localizations.twoFactorResend} (${formatCooldownSeconds(cooldownRemainingSeconds)})' : localizations.twoFactorResend; return ResendLink( label: label, - onPressed: provider.resendCode, + onPressed: onPressed, isDisabled: isDisabled, - isLoading: provider.isResending, + isLoading: isResending, ); } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/actions.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/actions.dart index 3f8f4a67..f3ae39cc 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/actions.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/actions.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + import 'package:pweb/controllers/multiple_payouts.dart'; +import 'package:pweb/controllers/payout_verification.dart'; +import 'package:pweb/utils/payment/payout_verification_flow.dart'; import 'package:pweb/widgets/dialogs/payment_status_dialog.dart'; @@ -8,7 +12,14 @@ Future handleMultiplePayoutSend( BuildContext context, MultiplePayoutsController controller, ) async { - final outcome = await controller.sendAndStorePayments(); + final verificationController = context.read(); + final verified = await runPayoutVerification( + context: context, + controller: verificationController, + ); + if (!verified) return; + + final outcome = await controller.sendAndGetOutcome(); if (!context.mounted) return; diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart index 8bfb6b32..b6e492c1 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart @@ -3,13 +3,17 @@ import 'package:flutter/material.dart'; import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pweb/controllers/multiple_payouts.dart'; +import 'package:pweb/controllers/payout_verification.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/actions.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/header.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/summary.dart'; import 'package:pweb/pages/dashboard/payouts/multiple/widgets/quote_status.dart'; +import 'package:pweb/pages/payment_methods/payment_page/send_button.dart'; import 'package:pweb/widgets/payment/source_wallet_selector.dart'; +import 'package:pweb/widgets/cooldown_hint.dart'; +import 'package:pweb/models/control_state.dart'; -import 'package:pweb/generated/i18n/app_localizations.dart'; +import 'package:provider/provider.dart'; class SourceQuotePanel extends StatelessWidget { @@ -25,7 +29,10 @@ class SourceQuotePanel extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; + final verificationController = + context.watch(); + final isCooldownActive = verificationController.isCooldownActive; + final canSend = controller.canSend && !isCooldownActive; return Container( width: double.infinity, padding: const EdgeInsets.all(12), @@ -51,22 +58,24 @@ class SourceQuotePanel extends StatelessWidget { MultipleQuoteStatusCard(controller: controller), const SizedBox(height: 12), Center( - child: ElevatedButton( - onPressed: controller.canSend - ? () => handleMultiplePayoutSend(context, controller) - : null, - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SendButton( + onPressed: () => handleMultiplePayoutSend(context, controller), + state: controller.isSending + ? ControlState.loading + : canSend + ? ControlState.enabled + : ControlState.disabled, ), - textStyle: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - child: Text(l10n.send), + if (isCooldownActive) ...[ + const SizedBox(height: 8), + CooldownHint( + seconds: verificationController.cooldownRemainingSeconds, + ), + ], + ], ), ), ], diff --git a/frontend/pweb/lib/pages/login/form.dart b/frontend/pweb/lib/pages/login/form.dart index 632ecb3b..1f72ce8c 100644 --- a/frontend/pweb/lib/pages/login/form.dart +++ b/frontend/pweb/lib/pages/login/form.dart @@ -15,6 +15,7 @@ import 'package:pweb/widgets/password/hint/short.dart'; import 'package:pweb/widgets/password/password.dart'; import 'package:pweb/widgets/username.dart'; import 'package:pweb/widgets/vspacer.dart'; +import 'package:pweb/controllers/email.dart'; import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/services/posthog.dart'; @@ -31,12 +32,11 @@ class LoginForm extends StatefulWidget { } class _LoginFormState extends State { - final TextEditingController _usernameController = TextEditingController(); + final EmailFieldController _emailController = EmailFieldController(); final TextEditingController _passwordController = TextEditingController(); final _formKey = GlobalKey(); // ValueNotifiers for validation state - final ValueNotifier _isUsernameAcceptable = ValueNotifier(false); final ValueNotifier _isPasswordAcceptable = ValueNotifier(false); @override @@ -44,8 +44,7 @@ class _LoginFormState extends State { super.initState(); final initialEmail = widget.initialEmail?.trim(); if (initialEmail != null && initialEmail.isNotEmpty) { - _usernameController.text = initialEmail; - _isUsernameAcceptable.value = true; + _emailController.setText(initialEmail); } } @@ -54,7 +53,7 @@ class _LoginFormState extends State { try { final outcome = await provider.login( - email: _usernameController.text, + email: _emailController.text, password: _passwordController.text, locale: context.read().locale.languageCode, ); @@ -74,9 +73,8 @@ class _LoginFormState extends State { @override void dispose() { - _usernameController.dispose(); + _emailController.dispose(); _passwordController.dispose(); - _isUsernameAcceptable.dispose(); _isPasswordAcceptable.dispose(); super.dispose(); } @@ -93,8 +91,7 @@ class _LoginFormState extends State { const LoginHeader(), const VSpacer(multiplier: 1.5), UsernameField( - controller: _usernameController, - onValid: (isValid) => _isUsernameAcceptable.value = isValid, + controller: _emailController, ), VSpacer(), defaulRulesPasswordField( @@ -105,7 +102,7 @@ class _LoginFormState extends State { ), VSpacer(multiplier: 2.0), ValueListenableBuilder( - valueListenable: _isUsernameAcceptable, + valueListenable: _emailController.isValid, builder: (context, isUsernameValid, child) => ValueListenableBuilder( valueListenable: _isPasswordAcceptable, builder: (context, isPasswordValid, child) => ButtonsRow( diff --git a/frontend/pweb/lib/pages/payment_methods/page.dart b/frontend/pweb/lib/pages/payment_methods/page.dart index 9c1e5d58..5fb2868d 100644 --- a/frontend/pweb/lib/pages/payment_methods/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/page.dart @@ -17,6 +17,9 @@ import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/services/posthog.dart'; import 'package:pweb/widgets/dialogs/payment_status_dialog.dart'; import 'package:pweb/controllers/payment_page.dart'; +import 'package:pweb/controllers/payout_verification.dart'; +import 'package:pweb/utils/payment/payout_verification_flow.dart'; +import 'package:pweb/models/control_state.dart'; class PaymentPage extends StatefulWidget { @@ -98,8 +101,15 @@ class _PaymentPageState extends State { final flowProvider = context.read(); final paymentProvider = context.read(); final controller = context.read(); + final verificationController = context.read(); if (paymentProvider.isLoading) return; + final verified = await runPayoutVerification( + context: context, + controller: verificationController, + ); + if (!verified || !mounted) return; + final isSuccess = await controller.sendPayment(); if (!mounted) return; @@ -117,11 +127,16 @@ class _PaymentPageState extends State { Widget build(BuildContext context) { final methodsProvider = context.watch(); final recipientProvider = context.watch(); + final verificationController = + context.watch(); final recipient = recipientProvider.currentObject; final filteredRecipients = filterRecipients( recipients: recipientProvider.recipients, query: _query, ); + final sendState = verificationController.isCooldownActive + ? ControlState.disabled + : ControlState.enabled; return PaymentPageBody( onBack: widget.onBack, @@ -132,6 +147,9 @@ class _PaymentPageState extends State { searchQuery: _query, filteredRecipients: filteredRecipients, methodsProvider: methodsProvider, + sendState: sendState, + cooldownRemainingSeconds: + verificationController.cooldownRemainingSeconds, onWalletSelected: context.read().selectWallet, searchController: _searchController, searchFocusNode: _searchFocusNode, 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..4b799f82 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/body.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/body.dart @@ -8,6 +8,7 @@ import 'package:pshared/provider/recipient/provider.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/pages/payment_methods/widgets/state_view.dart'; import 'package:pweb/pages/payment_methods/payment_page/page.dart'; +import 'package:pweb/models/control_state.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -20,6 +21,8 @@ class PaymentPageBody extends StatelessWidget { final String searchQuery; final List filteredRecipients; final PaymentMethodsProvider methodsProvider; + final ControlState sendState; + final int cooldownRemainingSeconds; final ValueChanged onWalletSelected; final PayoutDestination fallbackDestination; final TextEditingController searchController; @@ -38,6 +41,8 @@ class PaymentPageBody extends StatelessWidget { required this.searchQuery, required this.filteredRecipients, required this.methodsProvider, + required this.sendState, + required this.cooldownRemainingSeconds, required this.onWalletSelected, required this.fallbackDestination, required this.searchController, @@ -71,6 +76,8 @@ class PaymentPageBody extends StatelessWidget { filteredRecipients: filteredRecipients, onWalletSelected: onWalletSelected, fallbackDestination: fallbackDestination, + sendState: sendState, + cooldownRemainingSeconds: cooldownRemainingSeconds, searchController: searchController, searchFocusNode: searchFocusNode, onSearchChanged: onSearchChanged, 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..673411af 100644 --- a/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart +++ b/frontend/pweb/lib/pages/payment_methods/payment_page/page.dart @@ -13,7 +13,9 @@ 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/cooldown_hint.dart'; import 'package:pweb/widgets/sidebar/destinations.dart'; +import 'package:pweb/models/control_state.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -27,6 +29,8 @@ class PaymentPageContent extends StatelessWidget { final List filteredRecipients; final ValueChanged onWalletSelected; final PayoutDestination fallbackDestination; + final ControlState sendState; + final int cooldownRemainingSeconds; final TextEditingController searchController; final FocusNode searchFocusNode; final ValueChanged onSearchChanged; @@ -44,6 +48,8 @@ class PaymentPageContent extends StatelessWidget { required this.filteredRecipients, required this.onWalletSelected, required this.fallbackDestination, + required this.sendState, + required this.cooldownRemainingSeconds, required this.searchController, required this.searchFocusNode, required this.onSearchChanged, @@ -104,7 +110,20 @@ class PaymentPageContent extends StatelessWidget { SizedBox(height: dimensions.paddingLarge), const PaymentFormWidget(), SizedBox(height: dimensions.paddingXXXLarge), - SendButton(onPressed: onSend), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SendButton( + onPressed: onSend, + state: sendState, + ), + if (sendState == ControlState.disabled && + cooldownRemainingSeconds > 0) ...[ + SizedBox(height: dimensions.paddingSmall), + CooldownHint(seconds: cooldownRemainingSeconds), + ], + ], + ), SizedBox(height: dimensions.paddingLarge), ], ), diff --git a/frontend/pweb/lib/pages/payout_verification/page.dart b/frontend/pweb/lib/pages/payout_verification/page.dart new file mode 100644 index 00000000..f1d5576d --- /dev/null +++ b/frontend/pweb/lib/pages/payout_verification/page.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/controllers/payout_verification.dart'; +import 'package:pweb/pages/2fa/error_message.dart'; +import 'package:pweb/pages/2fa/input.dart'; +import 'package:pweb/pages/2fa/prompt.dart'; +import 'package:pweb/pages/2fa/resend.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PayoutVerificationPage extends StatelessWidget { + const PayoutVerificationPage({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, controller, child) { + if (controller.verificationSuccess) { + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.of(context).pop(true); + }); + } + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.twoFactorTitle), + ), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + TwoFactorPromptText(email: controller.target), + const SizedBox(height: 32), + TwoFactorCodeInput( + onCompleted: controller.submitCode, + ), + const SizedBox(height: 24), + if (controller.isSubmitting) + const Center(child: CircularProgressIndicator()) + else + ResendCodeButton( + onPressed: controller.resendCode, + isCooldownActive: controller.isCooldownActive, + isResending: controller.isResending, + cooldownRemainingSeconds: controller.cooldownRemainingSeconds, + ), + if (controller.hasError) ...[ + const SizedBox(height: 12), + ErrorMessage( + error: AppLocalizations.of(context)!.twoFactorError, + ), + ], + ], + ), + ), + ); + }, + ); + } +} diff --git a/frontend/pweb/lib/pages/report/cards/list.dart b/frontend/pweb/lib/pages/report/cards/list.dart index 0c10c234..bfe3f08d 100644 --- a/frontend/pweb/lib/pages/report/cards/list.dart +++ b/frontend/pweb/lib/pages/report/cards/list.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/payment/operation.dart'; +import 'package:pweb/models/load_more_state.dart'; import 'package:pweb/pages/report/cards/items.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -10,11 +11,15 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class OperationsCardsList extends StatelessWidget { final List operations; final ValueChanged? onTap; + final LoadMoreState loadMoreState; + final VoidCallback? onLoadMore; const OperationsCardsList({ super.key, required this.operations, this.onTap, + this.loadMoreState = LoadMoreState.hidden, + this.onLoadMore, }); @override @@ -26,18 +31,42 @@ class OperationsCardsList extends StatelessWidget { onTap: onTap, ); + if (operations.isEmpty) { + return Expanded( + child: Center( + child: Text( + loc.reportPaymentsEmpty, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ); + } + + final extraItems = loadMoreState == LoadMoreState.hidden ? 0 : 1; return Expanded( - child: operations.isEmpty - ? Center( - child: Text( - loc.reportPaymentsEmpty, - style: Theme.of(context).textTheme.bodyMedium, + child: ListView.builder( + itemCount: items.length + extraItems, + itemBuilder: (context, index) { + if (index < items.length) { + return items[index]; + } + if (loadMoreState == LoadMoreState.loading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: TextButton( + onPressed: onLoadMore, + child: Text(loc.loadMore), ), - ) - : ListView.builder( - itemCount: items.length, - itemBuilder: (context, index) => items[index], ), + ); + }, + ), ); } diff --git a/frontend/pweb/lib/pages/report/details/page.dart b/frontend/pweb/lib/pages/report/details/page.dart index bd73d28d..8c8b3977 100644 --- a/frontend/pweb/lib/pages/report/details/page.dart +++ b/frontend/pweb/lib/pages/report/details/page.dart @@ -4,16 +4,14 @@ import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/models/payment/payment.dart'; -import 'package:pshared/models/payment/status.dart'; import 'package:pshared/provider/payment/payments.dart'; import 'package:pweb/app/router/payout_routes.dart'; +import 'package:pweb/controllers/payment_details.dart'; import 'package:pweb/pages/report/details/content.dart'; import 'package:pweb/pages/report/details/states/error.dart'; import 'package:pweb/pages/report/details/states/not_found.dart'; import 'package:pweb/utils/report/download_act.dart'; -import 'package:pweb/utils/report/payment_mapper.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -26,39 +24,48 @@ class PaymentDetailsPage extends StatelessWidget { required this.paymentId, }); + @override + Widget build(BuildContext context) { + return ChangeNotifierProxyProvider( + create: (_) => PaymentDetailsController(paymentId: paymentId), + update: (_, payments, controller) => controller! + ..update(payments, paymentId), + child: const _PaymentDetailsView(), + ); + } +} + +class _PaymentDetailsView extends StatelessWidget { + const _PaymentDetailsView(); + @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), - child: Consumer( - builder: (context, provider, child) { + child: Consumer( + builder: (context, controller, child) { final loc = AppLocalizations.of(context)!; - if (provider.isLoading) { + if (controller.isLoading) { return const Center(child: CircularProgressIndicator()); } - if (provider.error != null) { + if (controller.error != null) { return PaymentDetailsError( - message: provider.error?.toString() ?? loc.noErrorInformation, - onRetry: () => provider.refresh(), + message: controller.error?.toString() ?? loc.noErrorInformation, + onRetry: () => controller.refresh(), ); } - final payment = _findPayment(provider.payments, paymentId); + final payment = controller.payment; if (payment == null) { return PaymentDetailsNotFound(onBack: () => _handleBack(context)); } - final status = statusFromPayment(payment); - final paymentRef = payment.paymentRef ?? ''; - final canDownload = status == OperationStatus.success && - paymentRef.trim().isNotEmpty; - return PaymentDetailsContent( payment: payment, onBack: () => _handleBack(context), - onDownloadAct: canDownload - ? () => downloadPaymentAct(context, paymentRef) + onDownloadAct: controller.canDownload + ? () => downloadPaymentAct(context, payment.paymentRef ?? '') : null, ); }, @@ -66,16 +73,6 @@ class PaymentDetailsPage extends StatelessWidget { ); } - Payment? _findPayment(List payments, String paymentId) { - final trimmed = paymentId.trim(); - if (trimmed.isEmpty) return null; - for (final payment in payments) { - if (payment.paymentRef == trimmed) return payment; - if (payment.idempotencyKey == trimmed) return payment; - } - return null; - } - void _handleBack(BuildContext context) { final router = GoRouter.of(context); if (router.canPop()) { diff --git a/frontend/pweb/lib/pages/report/page.dart b/frontend/pweb/lib/pages/report/page.dart index 51b209ae..b36e30a4 100644 --- a/frontend/pweb/lib/pages/report/page.dart +++ b/frontend/pweb/lib/pages/report/page.dart @@ -12,6 +12,7 @@ import 'package:pweb/controllers/report_operations.dart'; import 'package:pweb/pages/report/table/filters.dart'; import 'package:pweb/utils/report/payment_mapper.dart'; import 'package:pweb/app/router/payout_routes.dart'; +import 'package:pweb/models/load_more_state.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -124,6 +125,11 @@ class _OperationHistoryView extends StatelessWidget { OperationsCardsList( operations: filteredOperations, onTap: (operation) => _openPaymentDetails(context, operation), + loadMoreState: controller.loadMoreState, + onLoadMore: controller.loadMoreState == + LoadMoreState.available + ? () => controller.loadMore() + : null, ), ], ), diff --git a/frontend/pweb/lib/pages/signup/form/controllers.dart b/frontend/pweb/lib/pages/signup/form/controllers.dart index da78c9c8..a401f510 100644 --- a/frontend/pweb/lib/pages/signup/form/controllers.dart +++ b/frontend/pweb/lib/pages/signup/form/controllers.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:pweb/controllers/email.dart'; + class SignUpFormControllers { final TextEditingController companyName = TextEditingController(); final TextEditingController description = TextEditingController(); final TextEditingController firstName = TextEditingController(); final TextEditingController lastName = TextEditingController(); - final TextEditingController email = TextEditingController(); + final EmailFieldController email = EmailFieldController(); final TextEditingController password = TextEditingController(); final TextEditingController passwordConfirm = TextEditingController(); diff --git a/frontend/pweb/lib/pages/signup/form/email.dart b/frontend/pweb/lib/pages/signup/form/email.dart deleted file mode 100644 index 973a0f6f..00000000 --- a/frontend/pweb/lib/pages/signup/form/email.dart +++ /dev/null @@ -1,32 +0,0 @@ - -import 'package:flutter/material.dart'; - -import 'package:email_validator/email_validator.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - -//TODO: check with /widgets/username.dart -class EmailField extends StatelessWidget { - final TextEditingController controller; - - const EmailField({super.key, required this.controller}); - - - @override - Widget build(BuildContext context) { - return TextFormField( - controller: controller, - keyboardType: TextInputType.emailAddress, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.username, - hintText: AppLocalizations.of(context)!.usernameHint, - ), - validator: (value) { - if (value == null || !EmailValidator.validate(value)) { - return AppLocalizations.of(context)!.usernameErrorInvalid; - } - return null; - }, - ); - } -} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/signup/form/feilds.dart b/frontend/pweb/lib/pages/signup/form/feilds.dart index afd3fc2d..b1d4be67 100644 --- a/frontend/pweb/lib/pages/signup/form/feilds.dart +++ b/frontend/pweb/lib/pages/signup/form/feilds.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pweb/pages/signup/form/controllers.dart'; import 'package:pweb/pages/signup/form/description.dart'; -import 'package:pweb/pages/signup/form/email.dart'; +import 'package:pweb/widgets/username.dart'; import 'package:pweb/pages/signup/form/password_ui_controller.dart'; import 'package:pweb/pages/signup/header.dart'; import 'package:pweb/widgets/password/verify.dart'; @@ -45,7 +45,7 @@ class SignUpFormFields extends StatelessWidget { error: AppLocalizations.of(context)!.enterLastName, ), const VSpacer(), - EmailField(controller: controllers.email), + UsernameField(controller: controllers.email), const VSpacer(), SignUpPasswordUiController(controller: controllers.password), const VSpacer(multiplier: 2.0), diff --git a/frontend/pweb/lib/providers/multiple_payouts.dart b/frontend/pweb/lib/providers/multiple_payouts.dart index 46f489e5..4b5a29cc 100644 --- a/frontend/pweb/lib/providers/multiple_payouts.dart +++ b/frontend/pweb/lib/providers/multiple_payouts.dart @@ -174,10 +174,6 @@ class MultiplePayoutsProvider extends ChangeNotifier { } } - void setError(Object error) { - _setErrorObject(error); - } - Future> send() async { if (isBusy) return const []; @@ -219,12 +215,6 @@ class MultiplePayoutsProvider extends ChangeNotifier { } } - Future> sendAndStorePayments() async { - final result = await send(); - _payments?.addPayments(result); - return result; - } - void removeUploadedFile() { if (isBusy) return; diff --git a/frontend/pweb/lib/utils/payment/payout_verification_flow.dart b/frontend/pweb/lib/utils/payment/payout_verification_flow.dart new file mode 100644 index 00000000..c7c03e02 --- /dev/null +++ b/frontend/pweb/lib/utils/payment/payout_verification_flow.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:pweb/controllers/payout_verification.dart'; +import 'package:pweb/pages/payout_verification/page.dart'; +import 'package:pweb/utils/error/snackbar.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +Future runPayoutVerification({ + required BuildContext context, + required PayoutVerificationController controller, +}) async { + final localizations = AppLocalizations.of(context)!; + + if (controller.isCooldownActive) return false; + + try { + await controller.requestCode(); + } catch (e) { + await notifyUserOfError( + context: context, + errorSituation: localizations.verificationFailed, + exception: e, + ); + return false; + } + + if (!context.mounted) return false; + + final verified = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => ChangeNotifierProvider.value( + value: controller, + child: const PayoutVerificationPage(), + ), + ), + ); + + if (verified == true) { + controller.reset(); + } else { + controller.resetStatus(); + } + + return verified == true; +} diff --git a/frontend/pweb/lib/widgets/cooldown_hint.dart b/frontend/pweb/lib/widgets/cooldown_hint.dart new file mode 100644 index 00000000..fa57283e --- /dev/null +++ b/frontend/pweb/lib/widgets/cooldown_hint.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/utils/cooldown_format.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class CooldownHint extends StatelessWidget { + final int seconds; + + const CooldownHint({super.key, required this.seconds}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = AppLocalizations.of(context)!; + return Text( + l10n.payoutCooldown(formatCooldownSeconds(seconds)), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ); + } +} diff --git a/frontend/pweb/lib/widgets/username.dart b/frontend/pweb/lib/widgets/username.dart index e9452c88..efc43fff 100644 --- a/frontend/pweb/lib/widgets/username.dart +++ b/frontend/pweb/lib/widgets/username.dart @@ -1,43 +1,30 @@ import 'package:flutter/material.dart'; +import 'package:pweb/controllers/email.dart'; + import 'package:pweb/generated/i18n/app_localizations.dart'; class UsernameField extends StatelessWidget { - final TextEditingController controller; - final ValueChanged? onValid; + final EmailFieldController controller; const UsernameField({ super.key, required this.controller, - this.onValid, }); - String? _reportResult(String? msg) { - onValid?.call(msg == null); - return msg; - } - @override Widget build(BuildContext context) => TextFormField( - controller: controller, + controller: controller.textController, + keyboardType: TextInputType.emailAddress, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.username, hintText: AppLocalizations.of(context)!.usernameHint, ), validator: (value) { - return _reportResult((value?.isNotEmpty ?? false) ? null : AppLocalizations.of(context)!.usernameErrorInvalid); - // bool isValid = value != null && EmailValidator.validate(value); - // if (!isValid) { - // return _reportResult(AppLocalizations.of(context)!.usernameErrorInvalid); - // } - // final tld = value.split('.').last; - // isValid = tlds.contains(tld); - // if (!isValid) { - // return _reportResult(AppLocalizations.of(context)!.usernameUnknownTLD(tld)); - // } - // return _reportResult(null); + final locs = AppLocalizations.of(context)!; + return controller.validate(value, locs.usernameErrorInvalid); }, - onChanged: (value) => onValid?.call(value.isNotEmpty), + onChanged: controller.onChanged, ); } diff --git a/frontend/pweb/pubspec.yaml b/frontend/pweb/pubspec.yaml index 55b39a17..a190ace0 100644 --- a/frontend/pweb/pubspec.yaml +++ b/frontend/pweb/pubspec.yaml @@ -51,7 +51,6 @@ dependencies: web: ^1.1.0 share_plus: ^12.0.1 collection: ^1.18.0 - icann_tlds: ^1.0.0 flutter_timezone: ^5.0.1 json_annotation: ^4.10.0 go_router: ^17.0.0 -- 2.49.1