verification before payment and email fixes

This commit is contained in:
Arseni
2026-02-18 18:15:38 +03:00
parent 4dc182bfa2
commit e901ac3eb6
35 changed files with 1023 additions and 192 deletions

View File

@@ -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<String>? states,
}) async {
await _refresh(
limit: limit,
sourceRef: sourceRef,
destinationRef: destinationRef,
states: states,
showLoading: true,
updateError: true,
);
}
Future<void> refreshSilently({
int? limit,
String? sourceRef,
String? destinationRef,
List<String>? states,
}) async {
await _refresh(
limit: limit,
sourceRef: sourceRef,
destinationRef: destinationRef,
states: states,
showLoading: false,
updateError: false,
);
}
Future<void> _refresh({
int? limit,
String? sourceRef,
String? destinationRef,
List<String>? 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<Payment> items, {bool prepend = true}) {
if (items.isEmpty) return;
final current = List<Payment>.from(payments);
final existingRefs = <String>{};
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<List<Payment>> 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;
}
}

View File

@@ -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<VerificationResponse?> _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<VerificationResponse> 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<VerificationResponse> 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<void> 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<VerificationResponse?> resource) {
_resource = resource;
notifyListeners();
}
}

View File

@@ -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<VerificationResponse> 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<VerificationResponse> 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<void> 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(),
);
}
}

View File

@@ -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,

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:email_validator/email_validator.dart';
class EmailFieldController {
final TextEditingController textController;
final ValueNotifier<bool> isValid;
EmailFieldController({
TextEditingController? controller,
}) : textController = controller ?? TextEditingController(),
isValid = ValueNotifier<bool>(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();
}
}

View File

@@ -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<CsvPayoutRow> get rows => _provider?.rows ?? const <CsvPayoutRow>[];
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<void> 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 <Payment>[];
}
Future<MultiplePayoutSendOutcome> sendAndStorePayments() async {
final payments =
await _provider?.sendAndStorePayments() ?? const <Payment>[];
final hasError = _provider?.error != null;
Future<MultiplePayoutSendOutcome> 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);

View File

@@ -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<void> refresh() async {
await _payments?.refresh();
}
void _rebuild() {
_payment = _findPayment(_payments?.payments ?? const [], _paymentId);
notifyListeners();
}
Payment? _findPayment(List<Payment> 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();
}
}

View File

@@ -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<void> 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<void> 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<void> 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();
}
}

View File

@@ -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();
}
}

View File

@@ -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<void> 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();
}
}

View File

@@ -129,6 +129,13 @@
"twoFactorResend": "Didnt 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",

View File

@@ -129,6 +129,13 @@
"twoFactorResend": "Не получили код? Отправить снова",
"twoFactorTitle": "Двухфакторная аутентификация",
"twoFactorError": "Неверный код. Пожалуйста, попробуйте снова.",
"payoutCooldown": "Можно отправить через {time}",
"@payoutCooldown": {
"placeholders": {
"time": {}
}
},
"loadMore": "Показать еще",
"payoutNavDashboard": "Дашборд",
"payoutNavSendPayout": "Отправить выплату",
"payoutNavRecipients": "Получатели",

View File

@@ -0,0 +1,5 @@
enum LoadMoreState {
hidden,
available,
loading,
}

View File

@@ -23,6 +23,7 @@ class TwoFactorCodePage extends StatelessWidget {
Widget build(BuildContext context) {
return Consumer<TwoFactorProvider>(
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),

View File

@@ -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<TwoFactorProvider>().pendingLogin?.target ?? '',
),
AppLocalizations.of(context)!.twoFactorPrompt(email),
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
);

View File

@@ -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<TwoFactorProvider>();
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,
);
}

View File

@@ -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<void> handleMultiplePayoutSend(
BuildContext context,
MultiplePayoutsController controller,
) async {
final outcome = await controller.sendAndStorePayments();
final verificationController = context.read<PayoutVerificationController>();
final verified = await runPayoutVerification(
context: context,
controller: verificationController,
);
if (!verified) return;
final outcome = await controller.sendAndGetOutcome();
if (!context.mounted) return;

View File

@@ -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<PayoutVerificationController>();
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,
),
],
],
),
),
],

View File

@@ -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<LoginForm> {
final TextEditingController _usernameController = TextEditingController();
final EmailFieldController _emailController = EmailFieldController();
final TextEditingController _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
// ValueNotifiers for validation state
final ValueNotifier<bool> _isUsernameAcceptable = ValueNotifier<bool>(false);
final ValueNotifier<bool> _isPasswordAcceptable = ValueNotifier<bool>(false);
@override
@@ -44,8 +44,7 @@ class _LoginFormState extends State<LoginForm> {
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<LoginForm> {
try {
final outcome = await provider.login(
email: _usernameController.text,
email: _emailController.text,
password: _passwordController.text,
locale: context.read<LocaleProvider>().locale.languageCode,
);
@@ -74,9 +73,8 @@ class _LoginFormState extends State<LoginForm> {
@override
void dispose() {
_usernameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_isUsernameAcceptable.dispose();
_isPasswordAcceptable.dispose();
super.dispose();
}
@@ -93,8 +91,7 @@ class _LoginFormState extends State<LoginForm> {
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<LoginForm> {
),
VSpacer(multiplier: 2.0),
ValueListenableBuilder<bool>(
valueListenable: _isUsernameAcceptable,
valueListenable: _emailController.isValid,
builder: (context, isUsernameValid, child) => ValueListenableBuilder<bool>(
valueListenable: _isPasswordAcceptable,
builder: (context, isPasswordValid, child) => ButtonsRow(

View File

@@ -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<PaymentPage> {
final flowProvider = context.read<PaymentFlowProvider>();
final paymentProvider = context.read<PaymentProvider>();
final controller = context.read<PaymentPageController>();
final verificationController = context.read<PayoutVerificationController>();
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<PaymentPage> {
Widget build(BuildContext context) {
final methodsProvider = context.watch<PaymentMethodsProvider>();
final recipientProvider = context.watch<RecipientsProvider>();
final verificationController =
context.watch<PayoutVerificationController>();
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<PaymentPage> {
searchQuery: _query,
filteredRecipients: filteredRecipients,
methodsProvider: methodsProvider,
sendState: sendState,
cooldownRemainingSeconds:
verificationController.cooldownRemainingSeconds,
onWalletSelected: context.read<WalletsController>().selectWallet,
searchController: _searchController,
searchFocusNode: _searchFocusNode,

View File

@@ -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<Recipient> filteredRecipients;
final PaymentMethodsProvider methodsProvider;
final ControlState sendState;
final int cooldownRemainingSeconds;
final ValueChanged<Wallet> 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,

View File

@@ -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<Recipient> filteredRecipients;
final ValueChanged<Wallet> onWalletSelected;
final PayoutDestination fallbackDestination;
final ControlState sendState;
final int cooldownRemainingSeconds;
final TextEditingController searchController;
final FocusNode searchFocusNode;
final ValueChanged<String> 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),
],
),

View File

@@ -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<PayoutVerificationController>(
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,
),
],
],
),
),
);
},
);
}
}

View File

@@ -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<OperationItem> operations;
final ValueChanged<OperationItem>? 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],
),
);
},
),
);
}

View File

@@ -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<PaymentsProvider, PaymentDetailsController>(
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<PaymentsProvider>(
builder: (context, provider, child) {
child: Consumer<PaymentDetailsController>(
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<Payment> 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()) {

View File

@@ -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,
),
],
),

View File

@@ -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();

View File

@@ -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;
},
);
}
}

View File

@@ -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),

View File

@@ -174,10 +174,6 @@ class MultiplePayoutsProvider extends ChangeNotifier {
}
}
void setError(Object error) {
_setErrorObject(error);
}
Future<List<Payment>> send() async {
if (isBusy) return const <Payment>[];
@@ -219,12 +215,6 @@ class MultiplePayoutsProvider extends ChangeNotifier {
}
}
Future<List<Payment>> sendAndStorePayments() async {
final result = await send();
_payments?.addPayments(result);
return result;
}
void removeUploadedFile() {
if (isBusy) return;

View File

@@ -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<bool> 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<bool>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => ChangeNotifierProvider.value(
value: controller,
child: const PayoutVerificationPage(),
),
),
);
if (verified == true) {
controller.reset();
} else {
controller.resetStatus();
}
return verified == true;
}

View File

@@ -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,
);
}
}

View File

@@ -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<bool>? 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,
);
}

View File

@@ -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