redesigned payment page + a lot of fixes

This commit is contained in:
Arseni
2026-02-21 21:55:20 +03:00
parent a68aa2abff
commit 0c6fa03aba
208 changed files with 4062 additions and 2217 deletions

View File

@@ -15,6 +15,7 @@ class PayoutRoutes {
static const recipients = 'payout-recipients';
static const invitations = 'payout-invitations';
static const addRecipient = 'payout-add-recipient';
static const editRecipient = 'payout-edit-recipient';
static const payment = 'payout-payment';
static const settings = 'payout-settings';
static const reports = 'payout-reports';
@@ -24,20 +25,21 @@ class PayoutRoutes {
static const walletTopUp = 'payout-wallet-top-up';
static const paymentTypeQuery = 'paymentType';
static const returnToQuery = 'returnTo';
static const reportPaymentIdQuery = 'paymentId';
static const dashboardPath = '/dashboard';
static const recipientsPath = '/dashboard/recipients';
static const invitationsPath = '/dashboard/invitations';
static const addRecipientPath = '/dashboard/recipients/add';
static const paymentPath = '/dashboard/payment';
static const settingsPath = '/dashboard/settings';
static const reportsPath = '/dashboard/reports';
static const reportPaymentPath = '/dashboard/reports/payment';
static const methodsPath = '/dashboard/methods';
static const editWalletPath = '/dashboard/methods/edit';
static const walletTopUpPath = '/dashboard/wallet/top-up';
static const recipientsPath = '/recipients';
static const invitationsPath = '/invitations';
static const addRecipientPath = '/recipients/add';
static const editRecipientPath = '/recipients/edit';
static const paymentPath = '/payment';
static const settingsPath = '/settings';
static const reportsPath = '/reports';
static const reportPaymentPath = '/reports/payment';
static const methodsPath = '/methods';
static const editWalletPath = '/methods/edit';
static const walletTopUpPath = '/wallet/top-up';
static String nameFor(PayoutDestination destination) {
switch (destination) {
@@ -105,6 +107,8 @@ class PayoutRoutes {
return PayoutDestination.invitations;
case addRecipient:
return PayoutDestination.addrecipient;
case editRecipient:
return PayoutDestination.addrecipient;
case settings:
return PayoutDestination.settings;
case reports:
@@ -122,11 +126,9 @@ class PayoutRoutes {
static Map<String, String> buildQueryParameters({
PaymentType? paymentType,
PayoutDestination? returnTo,
}) {
final params = <String, String>{
if (paymentType != null) paymentTypeQuery: paymentType.name,
if (returnTo != null) returnToQuery: nameFor(returnTo),
};
return params;
}
@@ -138,13 +140,6 @@ class PayoutRoutes {
? null
: PaymentType.values.firstWhereOrNull((type) => type.name == raw);
static PayoutDestination fallbackFromState(
GoRouterState state, {
PayoutDestination defaultDestination = PayoutDestination.dashboard,
}) {
final raw = state.uri.queryParameters[returnToQuery];
return destinationFor(raw) ?? defaultDestination;
}
}
extension PayoutNavigation on BuildContext {
@@ -154,25 +149,11 @@ extension PayoutNavigation on BuildContext {
void goToPayment({
PaymentType? paymentType,
PayoutDestination? returnTo,
}) =>
goNamed(
PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: paymentType,
returnTo: returnTo,
),
);
void pushToPayment({
PaymentType? paymentType,
PayoutDestination? returnTo,
}) =>
pushNamed(
PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: paymentType,
returnTo: returnTo,
),
);
@@ -190,13 +171,7 @@ extension PayoutNavigation on BuildContext {
},
);
void pushToWalletTopUp({PayoutDestination? returnTo}) => pushNamed(
PayoutRoutes.walletTopUp,
queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo),
);
void pushToWalletTopUp() => pushNamed(PayoutRoutes.walletTopUp);
void pushToEditWallet({PayoutDestination? returnTo}) => pushNamed(
PayoutRoutes.editWallet,
queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo),
);
void pushToEditWallet() => pushNamed(PayoutRoutes.editWallet);
}

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/payment/updates.dart';
import 'package:pshared/provider/payout_verification.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/recipient/methods_cache.dart';
@@ -22,18 +23,18 @@ import 'package:pshared/provider/recipient/pmethods.dart';
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/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/multi_quotation.dart';
import 'package:pweb/controllers/payments/page.dart';
import 'package:pweb/controllers/payouts/payout_verification.dart';
import 'package:pweb/pages/invitations/page/page.dart';
import 'package:pweb/providers/multiple_payouts.dart';
import 'package:pweb/controllers/multi_quotation.dart';
import 'package:pweb/providers/quotation/quotation.dart';
import 'package:pweb/controllers/payouts/quotation.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/address_book/form/page.dart';
import 'package:pweb/pages/address_book/page/page.dart';
import 'package:pweb/pages/dashboard/dashboard.dart';
import 'package:pweb/pages/invitations/page.dart';
import 'package:pweb/pages/payment_methods/page.dart';
import 'package:pweb/pages/payout_page/send/page.dart';
import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
import 'package:pweb/pages/report/details/page.dart';
import 'package:pweb/pages/report/page.dart';
@@ -169,14 +170,28 @@ RouteBase payoutShellRoute() => ShellRoute(
provider!..update(organization, quotation),
),
ChangeNotifierProxyProvider3<
PaymentsProvider,
PaymentProvider,
MultiPaymentProvider,
PaymentsUpdatesProvider
>(
create: (_) => PaymentsUpdatesProvider(),
lazy: false,
update: (_, payments, payment, multiPayment, controller) =>
controller!..update(
paymentsProvider: payments,
paymentProvider: payment,
multiPaymentProvider: multiPayment,
),
),
ChangeNotifierProxyProvider2<
MultiQuotationProvider,
MultiPaymentProvider,
PaymentsProvider,
MultiplePayoutsProvider
>(
create: (_) => MultiplePayoutsProvider(),
update: (context, quotation, payment, payments, provider) =>
provider!..update(quotation, payment, payments),
update: (context, quotation, payment, provider) =>
provider!..update(quotation, payment),
),
ChangeNotifierProxyProvider2<
MultiplePayoutsProvider,
@@ -200,23 +215,19 @@ RouteBase payoutShellRoute() => ShellRoute(
onRecipientSelected: (recipient) => _startPayment(
context,
recipient: recipient,
returnTo: PayoutDestination.dashboard,
),
onGoToPaymentWithoutRecipient: (type) => _startPayment(
context,
recipient: null,
paymentType: type,
returnTo: PayoutDestination.dashboard,
),
onTopUp: (wallet) => _openWalletTopUp(
context,
wallet,
returnTo: PayoutDestination.dashboard,
),
onWalletTap: (wallet) => _openWalletEdit(
context,
wallet,
returnTo: PayoutDestination.dashboard,
),
),
),
@@ -231,11 +242,10 @@ RouteBase payoutShellRoute() => ShellRoute(
onRecipientSelected: (recipient) => _startPayment(
context,
recipient: recipient,
returnTo: PayoutDestination.recipients,
),
onAddRecipient: () => _openAddRecipient(context),
onEditRecipient: (recipient) =>
_openAddRecipient(context, recipient: recipient),
_openEditRecipient(context, recipient: recipient),
onDeleteRecipient: (recipient) async {
final confirmed = await showConfirmationDialog(
context: context,
@@ -269,7 +279,20 @@ RouteBase payoutShellRoute() => ShellRoute(
return NoTransitionPage(
child: AddressBookRecipientForm(
recipient: recipient,
onSaved: (_) => context.goToPayout(PayoutDestination.recipients),
onSaved: (_) => _popOrGo(context),
),
);
},
),
GoRoute(
name: PayoutRoutes.editRecipient,
path: PayoutRoutes.editRecipientPath,
pageBuilder: (context, _) {
final recipient = context.read<RecipientsProvider>().currentObject;
return NoTransitionPage(
child: AddressBookRecipientForm(
recipient: recipient,
onSaved: (_) => _popOrGo(context),
),
);
},
@@ -278,14 +301,11 @@ RouteBase payoutShellRoute() => ShellRoute(
name: PayoutRoutes.payment,
path: PayoutRoutes.paymentPath,
pageBuilder: (context, state) {
final fallbackDestination = PayoutRoutes.fallbackFromState(
state,
defaultDestination: PayoutDestination.dashboard,
);
final fallbackDestination = PayoutDestination.dashboard;
return NoTransitionPage(
child: PaymentPage(
onBack: (_) => _popOrGo(context, fallbackDestination),
onBack: (_) => _popOrGo(context),
initialPaymentType: PayoutRoutes.paymentTypeFromState(state),
fallbackDestination: fallbackDestination,
),
@@ -327,15 +347,11 @@ RouteBase payoutShellRoute() => ShellRoute(
final walletsProvider = context.read<WalletsController>();
final wallet = walletsProvider.selectedWallet;
final loc = AppLocalizations.of(context)!;
final fallbackDestination = PayoutRoutes.fallbackFromState(
state,
defaultDestination: PayoutDestination.methods,
);
return NoTransitionPage(
child: wallet != null
? WalletEditPage(
onBack: () => _popOrGo(context, fallbackDestination),
onBack: () => _popOrGo(context),
)
: Center(child: Text(loc.noWalletSelected)),
);
@@ -345,14 +361,10 @@ RouteBase payoutShellRoute() => ShellRoute(
name: PayoutRoutes.walletTopUp,
path: PayoutRoutes.walletTopUpPath,
pageBuilder: (context, state) {
final fallbackDestination = PayoutRoutes.fallbackFromState(
state,
defaultDestination: PayoutDestination.dashboard,
);
return NoTransitionPage(
child: WalletTopUpPage(
onBack: () => _popOrGo(context, fallbackDestination),
onBack: () => _popOrGo(context),
),
);
},
@@ -364,10 +376,14 @@ void _startPayment(
BuildContext context, {
Recipient? recipient,
PaymentType? paymentType,
required PayoutDestination returnTo,
}) {
context.read<RecipientsProvider>().setCurrentObject(recipient?.id);
context.pushToPayment(paymentType: paymentType, returnTo: returnTo);
context.pushNamed(
PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: paymentType,
),
);
}
void _openAddRecipient(BuildContext context, {Recipient? recipient}) {
@@ -375,28 +391,32 @@ void _openAddRecipient(BuildContext context, {Recipient? recipient}) {
context.pushNamed(PayoutRoutes.addRecipient);
}
void _openEditRecipient(BuildContext context, {required Recipient recipient}) {
context.read<RecipientsProvider>().setCurrentObject(recipient.id);
context.pushNamed(PayoutRoutes.editRecipient);
}
void _openWalletEdit(
BuildContext context,
Wallet wallet, {
required PayoutDestination returnTo,
}) {
Wallet wallet,
) {
context.read<WalletsController>().selectWallet(wallet);
context.pushToEditWallet(returnTo: returnTo);
context.pushToEditWallet();
}
void _openWalletTopUp(
BuildContext context,
Wallet wallet, {
required PayoutDestination returnTo,
}) {
Wallet wallet,
) {
context.read<WalletsController>().selectWallet(wallet);
context.pushToWalletTopUp(returnTo: returnTo);
context.pushToWalletTopUp();
}
void _popOrGo(BuildContext context, PayoutDestination destination) {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
void _popOrGo(BuildContext context) {
final router = GoRouter.of(context);
if (router.canPop()) {
router.pop();
} else {
context.goToPayout(destination);
context.goToPayout(PayoutDestination.dashboard);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/models/auth/state.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/models/account/account_loader.dart';
class AccountLoaderController extends ChangeNotifier {
AccountProvider? _provider;
AuthState? _handledState;
AccountLoaderAction? _action;
Object? _error;
AccountLoaderAction? get action => _action;
Object? get error => _error;
void update(AccountProvider provider) {
if (identical(_provider, provider)) return;
_provider?.removeListener(_handleProviderChanged);
_provider = provider;
_provider?.addListener(_handleProviderChanged);
_evaluate(provider);
}
AccountLoaderAction? consumeAction() {
final action = _action;
_action = null;
return action;
}
void _handleProviderChanged() {
final provider = _provider;
if (provider == null) return;
_evaluate(provider);
}
void _evaluate(AccountProvider provider) {
if (_handledState == provider.authState) return;
_handledState = provider.authState;
switch (provider.authState) {
case AuthState.error:
_error = provider.error ?? Exception('Authorization failed');
_action = AccountLoaderAction.showErrorAndGoToLogin;
notifyListeners();
break;
case AuthState.empty:
_error = null;
_action = AccountLoaderAction.goToLogin;
notifyListeners();
break;
default:
break;
}
}
@override
void dispose() {
_provider?.removeListener(_handleProviderChanged);
super.dispose();
}
}

View File

@@ -2,21 +2,22 @@ import 'package:flutter/material.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/models/edit_state.dart';
import 'package:pweb/models/state/edit_state.dart';
class AccountNameState extends ChangeNotifier {
AccountNameState({
class AccountNameController extends ChangeNotifier {
AccountNameController({
required this.initialFirstName,
required this.initialLastName,
required this.errorMessage,
required AccountProvider accountProvider,
}) : _accountProvider = accountProvider {
}) {
_firstNameController = TextEditingController(text: initialFirstName);
_lastNameController = TextEditingController(text: initialLastName);
_lastSyncedFirstName = initialFirstName;
_lastSyncedLastName = initialLastName;
}
final AccountProvider _accountProvider;
AccountProvider? _accountProvider;
final String initialFirstName;
final String initialLastName;
final String errorMessage;
@@ -26,6 +27,8 @@ class AccountNameState extends ChangeNotifier {
EditState _editState = EditState.view;
String _errorText = '';
bool _disposed = false;
String _lastSyncedFirstName = '';
String _lastSyncedLastName = '';
TextEditingController get firstNameController => _firstNameController;
TextEditingController get lastNameController => _lastNameController;
@@ -33,9 +36,12 @@ class AccountNameState extends ChangeNotifier {
String get errorText => _errorText;
bool get isEditing => _editState != EditState.view;
bool get isSaving => _editState == EditState.saving;
bool get isBusy => _accountProvider.isLoading || isSaving;
String get currentFirstName => _accountProvider.account?.name ?? initialFirstName;
String get currentLastName => _accountProvider.account?.lastName ?? initialLastName;
bool get isBusy => (_accountProvider?.isLoading ?? false) || isSaving;
String get currentFirstName =>
_accountProvider?.account?.name ?? initialFirstName;
String get currentLastName =>
_accountProvider?.account?.lastName ?? initialLastName;
String get currentFullName {
final first = currentFirstName.trim();
final last = currentLastName.trim();
@@ -45,6 +51,14 @@ class AccountNameState extends ChangeNotifier {
return '$first $last';
}
void update(AccountProvider accountProvider) {
_accountProvider = accountProvider;
final changed = _syncNamesFromProvider();
if (changed) {
notifyListeners();
}
}
void startEditing() => _setState(EditState.edit);
void cancelEditing() {
@@ -54,23 +68,17 @@ class AccountNameState extends ChangeNotifier {
_setState(EditState.view);
}
void syncNames(String latestFirstName, String latestLastName) {
if (isEditing) return;
if (_firstNameController.text != latestFirstName) {
_firstNameController.text = latestFirstName;
}
if (_lastNameController.text != latestLastName) {
_lastNameController.text = latestLastName;
}
}
Future<bool> save() async {
final accountProvider = _accountProvider;
if (accountProvider == null) return false;
final newFirstName = _firstNameController.text.trim();
final newLastName = _lastNameController.text.trim();
final currentFirst = currentFirstName;
final currentLast = currentLastName;
if (newFirstName.isEmpty || (newFirstName == currentFirst && newLastName == currentLast)) {
if (newFirstName.isEmpty ||
(newFirstName == currentFirst && newLastName == currentLast)) {
cancelEditing();
return false;
}
@@ -79,7 +87,10 @@ class AccountNameState extends ChangeNotifier {
_setState(EditState.saving);
try {
await _accountProvider.resetUsername(newFirstName, lastName: newLastName);
await accountProvider.resetUsername(
newFirstName,
lastName: newLastName,
);
_setState(EditState.view);
return true;
} catch (_) {
@@ -93,6 +104,23 @@ class AccountNameState extends ChangeNotifier {
}
}
bool _syncNamesFromProvider() {
if (isEditing) return false;
final latestFirstName = currentFirstName;
final latestLastName = currentLastName;
final didChange = latestFirstName != _lastSyncedFirstName ||
latestLastName != _lastSyncedLastName;
if (_firstNameController.text != latestFirstName) {
_firstNameController.text = latestFirstName;
}
if (_lastNameController.text != latestLastName) {
_lastNameController.text = latestLastName;
}
_lastSyncedFirstName = latestFirstName;
_lastSyncedLastName = latestLastName;
return didChange;
}
void _setState(EditState value) {
if (_disposed || _editState == value) return;
_editState = value;

View File

@@ -1,14 +1,14 @@
import 'package:flutter/material.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/models/edit_state.dart';
import 'package:pshared/api/responses/error/server.dart';
import 'package:pweb/models/password_field_type.dart';
import 'package:pweb/models/visibility.dart';
import 'package:pweb/models/state/edit_state.dart';
import 'package:pweb/models/auth/password_field_type.dart';
import 'package:pweb/models/state/visibility.dart';
class PasswordFormProvider extends ChangeNotifier {
class PasswordFormController extends ChangeNotifier {
final formKey = GlobalKey<FormState>();
final oldPasswordController = TextEditingController();
final newPasswordController = TextEditingController();

View File

@@ -0,0 +1,85 @@
import 'dart:async';
class CooldownController {
CooldownController({void Function()? onTick}) : _onTick = onTick;
final void Function()? _onTick;
Timer? _timer;
DateTime? _until;
int _remainingSeconds = 0;
int get remainingSeconds => _remainingSeconds;
bool get isActive => _remainingSeconds > 0;
DateTime? get until => _until;
void start(Duration duration) {
startUntil(DateTime.now().add(duration));
}
void startUntil(DateTime until) {
_until = until;
_restartTimer();
_syncRemaining(notify: true);
}
void syncUntil(DateTime? until, {bool notify = true}) {
if (until == null) {
stop(notify: notify);
return;
}
_until = until;
_restartTimer();
_syncRemaining(notify: notify);
}
void stop({bool notify = false}) {
_timer?.cancel();
_timer = null;
_until = null;
final hadRemaining = _remainingSeconds != 0;
_remainingSeconds = 0;
if (notify && hadRemaining) {
_onTick?.call();
}
}
void dispose() {
_timer?.cancel();
_timer = null;
}
void _restartTimer() {
_timer?.cancel();
_timer = null;
if (_remaining() <= 0) return;
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
final nextRemaining = _remaining();
if (nextRemaining <= 0) {
stop(notify: true);
return;
}
if (nextRemaining != _remainingSeconds) {
_remainingSeconds = nextRemaining;
_onTick?.call();
}
});
}
void _syncRemaining({required bool notify}) {
final nextRemaining = _remaining();
if (nextRemaining == _remainingSeconds) return;
_remainingSeconds = nextRemaining;
if (notify) {
_onTick?.call();
}
}
int _remaining() {
final until = _until;
if (until == null) return 0;
final remaining = until.difference(DateTime.now()).inSeconds;
return remaining < 0 ? 0 : remaining;
}
}

View File

@@ -0,0 +1,111 @@
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/permissions/descriptions/role.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/invitations.dart';
import 'package:pshared/provider/permissions.dart';
class InvitationsPageController extends ChangeNotifier {
PermissionsProvider? _permissions;
InvitationsProvider? _invitations;
AccountProvider? _account;
String? _selectedRoleRef;
int _expiryDays = 7;
String? get selectedRoleRef => _selectedRoleRef;
int get expiryDays => _expiryDays;
void update({
required PermissionsProvider permissions,
required InvitationsProvider invitations,
required AccountProvider account,
}) {
_permissions = permissions;
_invitations = invitations;
_account = account;
bootstrapRoleSelection(permissions.roleDescriptions);
}
void setExpiryDays(int value) {
if (_expiryDays == value) return;
_expiryDays = value;
notifyListeners();
}
void setSelectedRoleRef(String? roleRef) {
if (_selectedRoleRef == roleRef) return;
_selectedRoleRef = roleRef;
notifyListeners();
}
Future<RoleDescription?> createRole({
required String name,
String? description,
}) async {
final permissions = _permissions;
if (permissions == null) {
throw StateError('Permissions provider is not ready');
}
final normalizedDescription = description?.trim();
final created = await permissions.createRoleDescription(
name: name.trim(),
description: (normalizedDescription == null || normalizedDescription.isEmpty)
? null
: normalizedDescription,
);
if (created != null) {
setSelectedRoleRef(created.id);
}
return created;
}
Future<void> sendInvitation({
required String email,
required String name,
required String lastName,
required String comment,
}) async {
final invitations = _invitations;
final permissions = _permissions;
final account = _account?.account;
if (invitations == null) {
throw StateError('Invitations provider is not ready');
}
if (permissions == null) {
throw StateError('Permissions provider is not ready');
}
if (account == null) {
throw StateError('Account is not ready');
}
final roleRef = _selectedRoleRef ??
permissions.roleDescriptions.firstOrNull?.storable.id;
if (roleRef == null) {
throw StateError('Role is not selected');
}
await invitations.sendInvitation(
email: email.trim(),
name: name.trim(),
lastName: lastName.trim(),
comment: comment.trim(),
roleRef: roleRef,
inviterRef: account.id,
expiresAt: DateTime.now().toUtc().add(Duration(days: _expiryDays)),
);
}
void bootstrapRoleSelection(List<RoleDescription> roles) {
if (roles.isEmpty) return;
final firstRoleRef = roles.first.storable.id;
final isSelectedAvailable = _selectedRoleRef != null &&
roles.any((role) => role.storable.id == _selectedRoleRef);
if (isSelectedAvailable) return;
_selectedRoleRef = firstRoleRef;
notifyListeners();
}
}

View File

@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:collection';
import 'package:flutter/material.dart';
@@ -7,7 +6,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/models/state/load_more_state.dart';
import 'package:pweb/utils/report/operations.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
@@ -39,14 +38,7 @@ class ReportOperationsController 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());
}
}
_rebuildOperations();
}
@@ -122,11 +114,4 @@ class ReportOperationsController extends ChangeNotifier {
return left.start.isAtSameMomentAs(right.start) &&
left.end.isAtSameMomentAs(right.end);
}
@override
void dispose() {
_payments?.endAutoRefresh();
super.dispose();
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/status.dart';
import 'package:pweb/models/wallet/wallet_transaction.dart';
import 'package:pweb/providers/wallet_transactions.dart';
class WalletTransactionsController extends ChangeNotifier {
List<WalletTransaction> _filteredTransactions = [];
DateTimeRange? _dateRange;
final Set<OperationStatus> _selectedStatuses = {};
final Set<WalletTransactionType> _selectedTypes = {};
WalletTransactionsProvider? _provider;
List<WalletTransaction> get transactions =>
_provider?.transactions ?? const [];
List<WalletTransaction> get filteredTransactions => _filteredTransactions;
DateTimeRange? get dateRange => _dateRange;
Set<OperationStatus> get selectedStatuses => _selectedStatuses;
Set<WalletTransactionType> get selectedTypes => _selectedTypes;
bool get isLoading => _provider?.isLoading ?? false;
String? get error => _provider?.error;
bool get hasFilters =>
_dateRange != null ||
_selectedStatuses.isNotEmpty ||
_selectedTypes.isNotEmpty;
void update(WalletTransactionsProvider provider) {
if (identical(_provider, provider)) return;
_provider?.removeListener(_onProviderChanged);
_provider = provider;
_provider?.addListener(_onProviderChanged);
_rebuildFiltered(notify: false);
notifyListeners();
}
void setDateRange(DateTimeRange? range) {
_dateRange = range;
_rebuildFiltered();
}
void toggleStatus(OperationStatus status) {
if (_selectedStatuses.contains(status)) {
_selectedStatuses.remove(status);
} else {
_selectedStatuses.add(status);
}
_rebuildFiltered();
}
void toggleType(WalletTransactionType type) {
if (_selectedTypes.contains(type)) {
_selectedTypes.remove(type);
} else {
_selectedTypes.add(type);
}
_rebuildFiltered();
}
void resetFilters() {
_dateRange = null;
_selectedStatuses.clear();
_selectedTypes.clear();
_rebuildFiltered();
}
void _onProviderChanged() {
_rebuildFiltered();
}
void _rebuildFiltered({bool notify = true}) {
final source = _provider?.transactions ?? const <WalletTransaction>[];
_filteredTransactions = source.where((tx) {
final statusMatch =
_selectedStatuses.isEmpty || _selectedStatuses.contains(tx.status);
final typeMatch =
_selectedTypes.isEmpty || _selectedTypes.contains(tx.type);
final dateMatch = _dateRange == null ||
(tx.date.isAfter(_dateRange!.start.subtract(const Duration(seconds: 1))) &&
tx.date.isBefore(_dateRange!.end.add(const Duration(seconds: 1))));
return statusMatch && typeMatch && dateMatch;
}).toList();
if (notify) notifyListeners();
}
@override
void dispose() {
_provider?.removeListener(_onProviderChanged);
super.dispose();
}
}

View File

@@ -0,0 +1,274 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/methods_cache.dart';
import 'package:pweb/models/recipient/method_snapshot.dart';
import 'package:pweb/models/state/seed_state.dart';
import 'package:pweb/providers/address_book_recipient_form.dart';
import 'package:pweb/utils/payment/label.dart';
import 'package:pweb/widgets/dialogs/confirmation_dialog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class AddressBookRecipientFormController extends ChangeNotifier {
static final ListEquality<RecipientMethodSnapshot> _listEquality =
ListEquality<RecipientMethodSnapshot>();
final List<PaymentType> _supportedTypes;
final Map<PaymentType, List<RecipientMethodDraft>> _methods;
Recipient? _recipient;
RecipientMethodsCacheProvider? _methodsCache;
String _initialName = '';
String _initialEmail = '';
List<RecipientMethodSnapshot> _initialMethods = const [];
SeedState _snapshotState = SeedState.idle;
SeedState _seedState = SeedState.idle;
AddressBookRecipientFormController({
required List<PaymentType> supportedTypes,
}) : _supportedTypes = List.unmodifiable(supportedTypes),
_methods = {
for (final type in supportedTypes) type: <RecipientMethodDraft>[],
};
List<PaymentType> get supportedTypes => _supportedTypes;
Map<PaymentType, List<RecipientMethodDraft>> get methods => {
for (final entry in _methods.entries)
entry.key: List<RecipientMethodDraft>.unmodifiable(entry.value),
};
PaymentType? get preferredType =>
_supportedTypes.firstWhere(
(type) => _methods[type]?.isNotEmpty == true,
orElse: () => _supportedTypes.first,
);
bool get hasAnyMethod => _methods.values.any(
(entries) => entries.any((entry) => entry.data != null || entry.existing != null),
);
Future<void> saveForm({
required BuildContext context,
required GlobalKey<FormState> formKey,
required AddressBookRecipientFormProvider formState,
required String name,
required String email,
ValueChanged<Recipient?>? onSaved,
}) async {
final l10n = AppLocalizations.of(context)!;
if (!formKey.currentState!.validate() || !hasAnyMethod) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.recipientFormRule)),
);
return;
}
try {
final saved = await formState.save(
name: name,
email: email,
methodNames: _methodNames(context),
methodDrafts: allDrafts(),
);
onSaved?.call(saved);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.notificationError(l10n.noErrorInformation))),
);
}
}
Future<void> handleBack({
required BuildContext context,
required GlobalKey<FormState> formKey,
required AddressBookRecipientFormProvider formState,
required String name,
required String email,
ValueChanged<Recipient?>? onSaved,
}) async {
if (!context.mounted) return;
if (!hasUnsavedChanges(name: name, email: email)) {
onSaved?.call(null);
return;
}
final l10n = AppLocalizations.of(context)!;
final shouldSave = await showConfirmationDialog(
context: context,
title: l10n.unsavedChangesTitle,
message: l10n.unsavedChangesMessage,
confirmLabel: l10n.save,
cancelLabel: l10n.discard,
);
if (!context.mounted) return;
if (shouldSave) {
await saveForm(
context: context,
formKey: formKey,
formState: formState,
name: name,
email: email,
onSaved: onSaved,
);
} else {
onSaved?.call(null);
}
}
void update({
required Recipient? recipient,
required RecipientMethodsCacheProvider methodsCache,
}) {
if (!identical(_methodsCache, methodsCache)) {
_methodsCache?.removeListener(_handleCacheChange);
_methodsCache = methodsCache;
_methodsCache?.addListener(_handleCacheChange);
}
if (_recipient?.id != recipient?.id) {
_reset(recipient);
}
_maybeSeedFromCache();
}
bool hasUnsavedChanges({
required String name,
required String email,
}) {
if (_recipient == null) return false;
final methodsCache = _methodsCache;
if (methodsCache == null) return false;
_captureIfReady();
final nameChanged = name.trim() != _initialName.trim();
final emailChanged = email.trim() != _initialEmail.trim();
if (_snapshotState != SeedState.seeded) {
return nameChanged || emailChanged;
}
final current = _snapshotFrom();
final methodsChanged = !_listEquality.equals(_initialMethods, current);
return nameChanged || emailChanged || methodsChanged;
}
List<RecipientMethodDraft> allDrafts() =>
_methods.values.expand((entries) => entries).toList();
int? addMethod(PaymentType type) {
final entries = _methods[type];
if (entries == null) return null;
entries.add(RecipientMethodDraft(type: type));
notifyListeners();
return entries.length - 1;
}
void removeMethod(PaymentType type, int index) {
final entries = _methods[type];
if (entries == null) return;
if (index < 0 || index >= entries.length) return;
entries.removeAt(index);
notifyListeners();
}
void updateMethod(PaymentType type, int index, PaymentMethodData data) {
final entries = _methods[type];
if (entries == null) return;
if (index < 0 || index >= entries.length) return;
entries[index].data = data;
notifyListeners();
}
void _handleCacheChange() {
_maybeSeedFromCache();
}
void _reset(Recipient? recipient) {
_recipient = recipient;
_initialName = recipient?.name ?? '';
_initialEmail = recipient?.email ?? '';
_initialMethods = const [];
_snapshotState = SeedState.idle;
_seedState = SeedState.idle;
_resetMethods();
notifyListeners();
}
void _resetMethods() {
for (final entries in _methods.values) {
entries.clear();
}
}
void _maybeSeedFromCache() {
final recipient = _recipient;
final methodsCache = _methodsCache;
if (recipient == null || methodsCache == null) return;
if (_seedState == SeedState.seeded) return;
if (!methodsCache.hasMethodsFor(recipient.id)) return;
_seedState = SeedState.seeded;
_seedMethodsFromExisting(methodsCache.methodsForRecipient(recipient.id));
}
void _seedMethodsFromExisting(List<PaymentMethod> existing) {
_resetMethods();
for (final method in existing) {
final type = method.type;
final entries = _methods[type];
if (entries == null) continue;
entries.add(
RecipientMethodDraft(
type: type,
existing: method,
data: method.data,
),
);
}
_initialMethods = _snapshotFrom();
_snapshotState = SeedState.seeded;
notifyListeners();
}
void _captureIfReady() {
if (_snapshotState == SeedState.seeded) return;
final recipient = _recipient;
final methodsCache = _methodsCache;
if (recipient == null || methodsCache == null) return;
if (!methodsCache.hasMethodsFor(recipient.id)) return;
_initialMethods = _snapshotFrom();
_snapshotState = SeedState.seeded;
}
List<RecipientMethodSnapshot> _snapshotFrom() {
final snapshots = <RecipientMethodSnapshot>[];
for (final type in _supportedTypes) {
final entries = _methods[type] ?? const <RecipientMethodDraft>[];
for (final entry in entries) {
snapshots.add(RecipientMethodSnapshot.fromDraft(entry));
}
}
return snapshots;
}
Map<PaymentType, String> _methodNames(BuildContext context) => {
for (final type in _supportedTypes) type: getPaymentTypeLabel(context, type),
};
@override
void dispose() {
_methodsCache?.removeListener(_handleCacheChange);
super.dispose();
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/controllers/organization/address_book_recipient_form.dart';
class AddressBookRecipientFormSelectionController extends ChangeNotifier {
AddressBookRecipientFormController? _formController;
PaymentType? _selectedType;
int? _selectedIndex;
PaymentType? get selectedType => _selectedType;
int? get selectedIndex => _selectedIndex;
void update(AddressBookRecipientFormController formController) {
if (identical(_formController, formController)) return;
_formController?.removeListener(_handleFormChanged);
_formController = formController;
_formController?.addListener(_handleFormChanged);
_reconcileSelection();
}
void select(PaymentType type, int index) {
if (_selectedType == type && _selectedIndex == index) return;
_selectedType = type;
_selectedIndex = index;
notifyListeners();
}
void selectAfterAdd(PaymentType type, int? index) {
if (_selectedType == type && _selectedIndex == index) return;
_selectedType = type;
_selectedIndex = index;
notifyListeners();
}
void _handleFormChanged() {
_reconcileSelection();
}
void _reconcileSelection() {
final form = _formController;
if (form == null) return;
final types = form.supportedTypes;
if (types.isEmpty) return;
var nextType = _selectedType;
var nextIndex = _selectedIndex;
if (nextType == null || !types.contains(nextType)) {
nextType = form.preferredType ?? types.first;
nextIndex = null;
}
final entries = form.methods[nextType] ?? const [];
if (entries.isEmpty) {
nextIndex = null;
} else if (nextIndex == null || nextIndex < 0 || nextIndex >= entries.length) {
nextIndex = 0;
}
if (nextType == _selectedType && nextIndex == _selectedIndex) return;
_selectedType = nextType;
_selectedIndex = nextIndex;
notifyListeners();
}
@override
void dispose() {
_formController?.removeListener(_handleFormChanged);
super.dispose();
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
class PaymentAmountFieldController extends ChangeNotifier {
final TextEditingController textController;
PaymentAmountProvider? _provider;
bool _isSyncingText = false;
PaymentAmountFieldController({required double initialAmount})
: textController = TextEditingController(
text: amountToString(initialAmount),
);
void update(PaymentAmountProvider provider) {
if (identical(_provider, provider)) return;
_provider?.removeListener(_handleProviderChanged);
_provider = provider;
_provider?.addListener(_handleProviderChanged);
_syncTextWithAmount(provider.amount);
}
void handleChanged(String value) {
if (_isSyncingText) return;
final parsed = _parseAmount(value);
if (parsed != null) {
_provider?.setAmount(parsed);
}
}
void _handleProviderChanged() {
final provider = _provider;
if (provider == null) return;
_syncTextWithAmount(provider.amount);
}
double? _parseAmount(String value) {
final parsed = parseMoneyAmount(
value.replaceAll(',', '.'),
fallback: double.nan,
);
return parsed.isNaN ? null : parsed;
}
void _syncTextWithAmount(double amount) {
final parsedText = _parseAmount(textController.text);
if (parsedText != null && parsedText == amount) return;
final nextText = amountToString(amount);
_isSyncingText = true;
textController.value = TextEditingValue(
text: nextText,
selection: TextSelection.collapsed(offset: nextText.length),
);
_isSyncingText = false;
}
@override
void dispose() {
_provider?.removeListener(_handleProviderChanged);
textController.dispose();
super.dispose();
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/payment.dart';
@@ -37,14 +35,7 @@ class PaymentDetailsController extends ChangeNotifier {
}
if (!identical(_payments, provider)) {
_payments?.endAutoRefresh();
_payments = provider;
_payments?.beginAutoRefresh();
if (provider.isReady || provider.isLoading) {
unawaited(_payments?.refreshSilently());
} else {
unawaited(_payments?.refresh());
}
}
_rebuild();
@@ -68,10 +59,4 @@ class PaymentDetailsController extends ChangeNotifier {
}
return null;
}
@override
void dispose() {
_payments?.endAutoRefresh();
super.dispose();
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:pshared/provider/payment/flow.dart';
@@ -5,6 +7,8 @@ import 'package:pshared/provider/payment/provider.dart';
import 'package:pshared/provider/payment/quotation/quotation.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/services/posthog.dart';
class PaymentPageController extends ChangeNotifier {
PaymentProvider? _payment;
@@ -58,6 +62,11 @@ class PaymentPageController extends ChangeNotifier {
_recipients?.setCurrentObject(null);
}
void handleSuccess() {
unawaited(PosthogService.paymentInitiated(method: _flow?.selectedType));
resetAfterSuccess();
}
void _setSending(bool value) {
if (_isSending == value) return;
_isSending = value;

View File

@@ -0,0 +1,43 @@
import 'package:flutter/widgets.dart';
import 'package:pweb/models/state/visibility.dart';
class PaymentPageUiController extends ChangeNotifier {
final TextEditingController searchController = TextEditingController();
final FocusNode searchFocusNode = FocusNode();
String _query = '';
VisibilityState _paymentDetailsVisibility = VisibilityState.hidden;
String get query => _query;
VisibilityState get paymentDetailsVisibility => _paymentDetailsVisibility;
void setQuery(String query) {
if (_query == query) return;
_query = query;
notifyListeners();
}
void clearSearch() {
if (searchController.text.isNotEmpty) {
searchController.clear();
}
searchFocusNode.unfocus();
setQuery('');
}
void togglePaymentDetails() {
_paymentDetailsVisibility = _paymentDetailsVisibility == VisibilityState.visible
? VisibilityState.hidden
: VisibilityState.visible;
notifyListeners();
}
@override
void dispose() {
searchController.dispose();
searchFocusNode.dispose();
super.dispose();
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:pshared/models/payment/operation.dart';
@@ -19,14 +17,7 @@ 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();
}
@@ -39,10 +30,4 @@ class RecentPaymentsController extends ChangeNotifier {
notifyListeners();
}
@override
void dispose() {
_payments?.endAutoRefresh();
super.dispose();
}
}

View File

@@ -2,15 +2,9 @@ import 'package:flutter/foundation.dart';
import 'package:pshared/provider/payment/multiple/quotation.dart';
import 'package:pweb/providers/quotation/auto_refresh.dart';
class MultiQuotationController extends ChangeNotifier {
static const Duration _autoRefreshLead = Duration(seconds: 5);
MultiQuotationProvider? _quotation;
final QuotationAutoRefreshController _autoRefreshController =
QuotationAutoRefreshController();
void update(MultiQuotationProvider quotation) {
if (identical(_quotation, quotation)) return;
@@ -32,37 +26,12 @@ class MultiQuotationController extends ChangeNotifier {
}
void _handleQuotationChanged() {
_syncAutoRefresh();
notifyListeners();
}
void _syncAutoRefresh() {
final quotation = _quotation;
if (quotation == null) {
_autoRefreshController.reset();
return;
}
final expiresAt = quoteExpiresAt;
final scheduledAt = expiresAt?.subtract(_autoRefreshLead);
_autoRefreshController.setEnabled(true);
_autoRefreshController.sync(
isLoading: quotation.isLoading,
canRefresh: quotation.canRefresh,
expiresAt: scheduledAt,
onRefresh: _refreshQuotation,
);
}
Future<void> _refreshQuotation() async {
await _quotation?.refreshQuotation();
}
@override
void dispose() {
_quotation?.removeListener(_handleQuotationChanged);
_autoRefreshController.dispose();
super.dispose();
}
}

View File

@@ -6,8 +6,8 @@ import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/quote/status_type.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/models/multiple_payouts/csv_row.dart';
import 'package:pweb/models/multiple_payouts/state.dart';
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
import 'package:pweb/models/payment/multiple_payouts/state.dart';
import 'package:pweb/providers/multiple_payouts.dart';
import 'package:pweb/services/payments/csv_input.dart';

View File

@@ -1,20 +1,23 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:pshared/provider/payout_verification.dart';
import 'package:pweb/models/flow_status.dart';
import 'package:pweb/controllers/common/cooldown.dart';
import 'package:pweb/models/state/flow_status.dart';
class PayoutVerificationController extends ChangeNotifier {
PayoutVerificationController() {
_cooldown = CooldownController(onTick: () => notifyListeners());
}
PayoutVerificationProvider? _provider;
FlowStatus _status = FlowStatus.idle;
Object? _error;
Timer? _cooldownTimer;
int _cooldownRemainingSeconds = 0;
DateTime? _cooldownUntil;
late final CooldownController _cooldown;
String? _contextKey;
String? _cooldownContextKey;
FlowStatus get status => _status;
bool get isSubmitting => _status == FlowStatus.submitting;
@@ -23,8 +26,17 @@ class PayoutVerificationController extends ChangeNotifier {
bool get verificationSuccess => _status == FlowStatus.success;
Object? get error => _error;
String get target => _provider?.target ?? '';
int get cooldownRemainingSeconds => _cooldownRemainingSeconds;
bool get isCooldownActive => _cooldownRemainingSeconds > 0;
int get cooldownRemainingSeconds => _cooldown.remainingSeconds;
bool get isCooldownActive => _cooldown.isActive;
bool isCooldownActiveFor(String? contextKey) {
if (!_cooldown.isActive) return false;
return _cooldownContextKey == contextKey;
}
int cooldownRemainingSecondsFor(String? contextKey) {
if (_cooldownContextKey != contextKey) return 0;
return _cooldown.remainingSeconds;
}
void update(PayoutVerificationProvider provider) {
if (identical(_provider, provider)) return;
@@ -34,11 +46,19 @@ class PayoutVerificationController extends ChangeNotifier {
_syncCooldown(provider.cooldownUntil);
}
void setContextKey(String? contextKey) {
if (_contextKey == contextKey) return;
_contextKey = contextKey;
_cooldownContextKey = null;
_cooldown.stop();
}
Future<void> requestCode() async {
final provider = _provider;
if (provider == null) {
throw StateError('Payout verification provider is not ready');
}
_bindCooldownContext();
_error = null;
_setStatus(FlowStatus.submitting);
try {
@@ -75,7 +95,7 @@ class PayoutVerificationController extends ChangeNotifier {
throw StateError('Payout verification provider is not ready');
}
if (isResending || isCooldownActive) return;
_bindCooldownContext();
_error = null;
_setStatus(FlowStatus.resending);
@@ -91,7 +111,9 @@ class PayoutVerificationController extends ChangeNotifier {
void reset() {
_error = null;
_setStatus(FlowStatus.idle);
_stopCooldown();
_cooldown.stop();
_cooldownContextKey = null;
_contextKey = null;
_provider?.reset();
}
@@ -106,67 +128,38 @@ class PayoutVerificationController extends ChangeNotifier {
}
void _syncCooldown(DateTime? until) {
if (_cooldownContextKey == null || _cooldownContextKey != _contextKey) {
_cooldown.stop(notify: _cooldown.isActive);
return;
}
if (until == null) {
_stopCooldown(notify: _cooldownRemainingSeconds != 0);
_cooldown.stop(notify: _cooldown.isActive);
return;
}
if (!_isCooldownActive(until) && _cooldownRemainingSeconds != 0) {
_stopCooldown(notify: true);
if (!_isCooldownActive(until)) {
_cooldown.stop(notify: _cooldown.isActive);
return;
}
if (_cooldownUntil == null || _cooldownUntil != until) {
_startCooldownUntil(until);
final currentUntil = _cooldown.until;
if (currentUntil == null || !currentUntil.isAtSameMomentAs(until)) {
_cooldown.syncUntil(until, notify: true);
}
}
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 _bindCooldownContext() {
final key = _contextKey;
if (key == null) {
_cooldownContextKey = null;
_cooldown.stop();
return;
}
if (_cooldownContextKey == key) return;
_cooldown.stop();
_cooldownContextKey = key;
}
void _setStatus(FlowStatus status) {
@@ -178,7 +171,7 @@ class PayoutVerificationController extends ChangeNotifier {
@override
void dispose() {
_provider?.removeListener(_onProviderChanged);
_stopCooldown();
_cooldown.dispose();
super.dispose();
}
}

View File

@@ -6,14 +6,9 @@ import 'package:pshared/models/auto_refresh_mode.dart';
import 'package:pshared/models/payment/quote/status_type.dart';
import 'package:pshared/provider/payment/quotation/quotation.dart';
import 'package:pweb/providers/quotation/auto_refresh.dart';
class QuotationController extends ChangeNotifier {
QuotationProvider? _quotation;
AutoRefreshMode _autoRefreshMode = AutoRefreshMode.on;
final QuotationAutoRefreshController _autoRefreshController =
QuotationAutoRefreshController();
Timer? _ticker;
void update(QuotationProvider quotation) {
@@ -28,7 +23,8 @@ class QuotationController extends ChangeNotifier {
Exception? get error => _quotation?.error;
bool get canRefresh => _quotation?.canRefresh ?? false;
bool get isReady => _quotation?.isReady ?? false;
AutoRefreshMode get autoRefreshMode => _autoRefreshMode;
AutoRefreshMode get autoRefreshMode =>
_quotation?.autoRefreshMode ?? AutoRefreshMode.on;
DateTime? get quoteExpiresAt => _quotation?.quoteExpiresAt;
@@ -55,10 +51,7 @@ class QuotationController extends ChangeNotifier {
bool get hasLiveQuote => isReady && _quotation?.quotation != null && !isExpired;
void setAutoRefreshMode(AutoRefreshMode mode) {
if (_autoRefreshMode == mode) return;
_autoRefreshMode = mode;
_syncAutoRefresh();
notifyListeners();
_quotation?.setAutoRefreshMode(mode);
}
void refreshQuotation() {
@@ -66,7 +59,6 @@ class QuotationController extends ChangeNotifier {
}
void _handleQuotationChanged() {
_syncAutoRefresh();
_syncTicker();
notifyListeners();
}
@@ -103,33 +95,10 @@ class QuotationController extends ChangeNotifier {
_ticker = null;
}
void _syncAutoRefresh() {
final quotation = _quotation;
if (quotation == null) {
_autoRefreshController.reset();
return;
}
final isAutoRefreshEnabled = _autoRefreshMode == AutoRefreshMode.on;
_autoRefreshController.setEnabled(isAutoRefreshEnabled);
final canAutoRefresh = isAutoRefreshEnabled && quotation.canRefresh;
_autoRefreshController.sync(
isLoading: quotation.isLoading,
canRefresh: canAutoRefresh,
expiresAt: quoteExpiresAt,
onRefresh: _refreshQuotation,
);
}
Future<void> _refreshQuotation() async {
await _quotation?.refreshQuotation();
}
@override
void dispose() {
_quotation?.removeListener(_handleQuotationChanged);
_autoRefreshController.dispose();
_stopTicker();
super.dispose();
}
}
}

View File

@@ -1,12 +1,11 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/models/flow_status.dart';
import 'package:pweb/models/resend/action_result.dart';
import 'package:pweb/models/resend/avaliability.dart';
import 'package:pweb/controllers/common/cooldown.dart';
import 'package:pweb/models/state/flow_status.dart';
import 'package:pweb/models/auth/resend/action_result.dart';
import 'package:pweb/models/auth/resend/avaliability.dart';
class SignupConfirmationCardController extends ChangeNotifier {
@@ -14,18 +13,17 @@ class SignupConfirmationCardController extends ChangeNotifier {
required AccountProvider accountProvider,
Duration defaultCooldown = const Duration(seconds: 60),
}) : _accountProvider = accountProvider,
_defaultCooldown = defaultCooldown;
_defaultCooldown = defaultCooldown {
_cooldown = CooldownController(onTick: () => notifyListeners());
}
final AccountProvider _accountProvider;
final Duration _defaultCooldown;
Timer? _cooldownTimer;
DateTime? _cooldownUntil;
int _cooldownRemainingSeconds = 0;
late final CooldownController _cooldown;
FlowStatus _resendState = FlowStatus.idle;
String? _email;
int get cooldownRemainingSeconds => _cooldownRemainingSeconds;
int get cooldownRemainingSeconds => _cooldown.remainingSeconds;
ResendAvailability get resendAvailability {
final email = _email;
if (email == null || email.isEmpty) {
@@ -34,7 +32,7 @@ class SignupConfirmationCardController extends ChangeNotifier {
if (_resendState == FlowStatus.submitting) {
return ResendAvailability.resending;
}
if (_cooldownRemainingSeconds > 0) {
if (_cooldown.isActive) {
return ResendAvailability.cooldown;
}
return ResendAvailability.available;
@@ -85,43 +83,12 @@ class SignupConfirmationCardController extends ChangeNotifier {
@override
void dispose() {
_cooldownTimer?.cancel();
_cooldown.dispose();
super.dispose();
}
void _startCooldown(Duration duration) {
_cooldownTimer?.cancel();
_cooldownUntil = DateTime.now().add(duration);
_syncRemaining();
if (_cooldownRemainingSeconds <= 0) {
_cooldownUntil = null;
notifyListeners();
return;
}
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
_syncRemaining();
if (_cooldownRemainingSeconds <= 0) {
timer.cancel();
_cooldownUntil = null;
notifyListeners();
}
});
}
void _syncRemaining() {
final remaining = _cooldownRemaining();
if (remaining == _cooldownRemainingSeconds) return;
_cooldownRemainingSeconds = remaining;
notifyListeners();
}
int _cooldownRemaining() {
final until = _cooldownUntil;
if (until == null) return 0;
final remaining = until.difference(DateTime.now()).inSeconds;
return remaining < 0 ? 0 : remaining;
_cooldown.start(duration);
}
void _setResendState(FlowStatus state) {

View File

@@ -703,6 +703,9 @@
"accountVerifiedDescription": "Your account has been successfully verified. You can now log in to access your account.",
"retryVerification": "Retry Verification",
"save": "Save",
"discard": "Discard",
"unsavedChangesTitle": "Save changes?",
"unsavedChangesMessage": "You have unsaved changes.",
"editWallet": "Edit Wallet",
"userNamePlaceholder": "User Name",
"noWalletSelected": "No wallet selected",

View File

@@ -705,6 +705,9 @@
"accountVerifiedDescription": "Ваш аккаунт успешно подтвержден. Теперь вы можете войти, чтобы получить доступ к своему аккаунту",
"retryVerification": "Повторить подтверждение",
"save": "Сохранить",
"discard": "Не сохранять",
"unsavedChangesTitle": "Сохранить изменения?",
"unsavedChangesMessage": "У вас есть несохранённые изменения.",
"editWallet": "Редактировать кошелек",
"userNamePlaceholder": "Имя пользователя",
"noWalletSelected": "Кошелек не выбран",

View File

@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
// ignore: depend_on_referenced_packages
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:logging/logging.dart';
@@ -29,10 +31,12 @@ import 'package:pweb/app/app.dart';
import 'package:pweb/pages/invitations/widgets/list/view_model.dart';
import 'package:pweb/app/timeago.dart';
import 'package:pweb/providers/two_factor.dart';
import 'package:pweb/controllers/operations/wallet_transactions.dart';
import 'package:pweb/providers/wallet_transactions.dart';
import 'package:pweb/services/posthog.dart';
import 'package:pweb/services/wallet_transactions.dart';
import 'package:pweb/providers/account.dart';
import 'package:pweb/providers/locale.dart';
void _setupLogging() {
Logger.root.level = Level.ALL;
@@ -51,13 +55,16 @@ void main() async {
_setupLogging();
setUrlStrategy(PathUrlStrategy());
GoRouter.optionURLReflectsImperativeAPIs = true;
initializeTimeagoLocales();
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => LocaleProvider(null)),
ChangeNotifierProvider<LocaleProvider>(
create: (_) => PwebLocaleProvider(null),
),
ChangeNotifierProxyProvider<LocaleProvider, AccountProvider>(
create: (_) => PwebAccountProvider(),
update: (context, localeProvider, provider) =>
@@ -68,7 +75,13 @@ void main() async {
update: (context, accountProvider, provider) =>
provider!..update(accountProvider),
),
ChangeNotifierProvider(create: (_) => OrganizationsProvider()),
ChangeNotifierProxyProvider<AccountProvider, OrganizationsProvider>(
//TODO controll scope of the provider
create: (_) => OrganizationsProvider(),
lazy: false,
update: (_, accountProvider, organizations) =>
organizations!..updateAccount(accountProvider),
),
ChangeNotifierProxyProvider<OrganizationsProvider, PermissionsProvider>(
create: (_) => PermissionsProvider(),
update: (context, orgnization, provider) =>
@@ -130,9 +143,16 @@ void main() async {
update: (_, wallets, controller) => controller!..update(wallets),
),
ChangeNotifierProvider(
create: (_) =>
WalletTransactionsProvider(MockWalletTransactionsService())
..load(),
create: (_) => WalletTransactionsProvider(
MockWalletTransactionsService(),
),
),
ChangeNotifierProxyProvider<
WalletTransactionsProvider,
WalletTransactionsController
>(
create: (_) => WalletTransactionsController(),
update: (_, provider, controller) => controller!..update(provider),
),
],
child: const PayApp(),

View File

@@ -0,0 +1,4 @@
enum AccountLoaderAction {
goToLogin,
showErrorAndGoToLogin,
}

View File

@@ -1,25 +1,35 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/auto_refresh_mode.dart';
import 'package:pshared/models/payment/quote/status_type.dart';
import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/body.dart';
import 'package:pweb/providers/quotation/quotation.dart';
import 'package:pweb/controllers/payouts/quotation.dart';
import 'package:pweb/utils/quote_duration_format.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class QuoteStatus extends StatelessWidget {
final double spacing;
class QuoteStatusData {
final QuoteStatusType statusType;
final String statusText;
final String? helperText;
final bool isLoading;
final bool canRefresh;
final bool showPrimaryRefresh;
final AutoRefreshMode autoRefreshMode;
const QuoteStatus({super.key, required this.spacing});
const QuoteStatusData({
required this.statusType,
required this.statusText,
required this.helperText,
required this.isLoading,
required this.canRefresh,
required this.showPrimaryRefresh,
required this.autoRefreshMode,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final controller = context.watch<QuotationController>();
static QuoteStatusData resolve({
required QuotationController controller,
required AppLocalizations loc,
}) {
final timeLeft = controller.timeLeft;
final isLoading = controller.isLoading;
final statusType = controller.quoteStatus;
@@ -54,8 +64,7 @@ class QuoteStatus extends StatelessWidget {
statusType == QuoteStatusType.error ||
statusType == QuoteStatusType.missing);
return QuoteStatusBody(
spacing: spacing,
return QuoteStatusData(
statusType: statusType,
statusText: statusText,
helperText: helperText,
@@ -63,8 +72,6 @@ class QuoteStatus extends StatelessWidget {
canRefresh: canRefresh,
showPrimaryRefresh: showPrimaryRefresh,
autoRefreshMode: autoRefreshMode,
onAutoRefreshModeChanged: controller.setAutoRefreshMode,
onRefresh: controller.refreshQuotation,
);
}
}
}

View File

@@ -1,11 +1,9 @@
class PaymentSummaryValues {
final String sentAmount;
final String fee;
final String recipientReceives;
final String total;
const PaymentSummaryValues({
required this.sentAmount,
required this.fee,
required this.recipientReceives,
required this.total,

View File

@@ -0,0 +1,62 @@
import 'package:collection/collection.dart';
import 'package:pshared/data/mapper/payment/method.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
class RecipientMethodSnapshot {
final PaymentType type;
final String? existingId;
final Map<String, dynamic>? data;
final Map<String, String>? metadata;
static final DeepCollectionEquality _mapEquality =
const DeepCollectionEquality();
const RecipientMethodSnapshot({
required this.type,
required this.existingId,
required this.data,
required this.metadata,
});
factory RecipientMethodSnapshot.fromDraft(RecipientMethodDraft draft) {
return RecipientMethodSnapshot(
type: draft.type,
existingId: draft.existing?.id,
data: _dataToSnapshot(draft.data),
metadata: _metadataToSnapshot(draft.data),
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is RecipientMethodSnapshot &&
other.type == type &&
other.existingId == existingId &&
_mapEquality.equals(other.data, data) &&
_mapEquality.equals(other.metadata, metadata);
}
@override
int get hashCode => Object.hash(
type,
existingId,
_mapEquality.hash(data),
_mapEquality.hash(metadata),
);
}
Map<String, dynamic>? _dataToSnapshot(PaymentMethodData? data) {
if (data == null) return null;
return data.toJsonMap();
}
Map<String, String>? _metadataToSnapshot(PaymentMethodData? data) {
final metadata = data?.metadata;
if (metadata == null) return null;
return Map<String, String>.from(metadata);
}

View File

@@ -1,9 +0,0 @@
class RoleDraft {
final String name;
final String description;
const RoleDraft({
required this.name,
required this.description,
});
}

View File

@@ -2,15 +2,14 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/controllers/organization/address_book_recipient_form.dart';
import 'package:pweb/controllers/organization/address_book_recipient_form_selection.dart';
import 'package:pweb/pages/address_book/form/view.dart';
import 'package:pweb/providers/address_book_recipient_form.dart';
import 'package:pweb/utils/payment/availability.dart';
class AddressBookRecipientFormBody extends StatefulWidget {
class AddressBookRecipientFormBody extends StatelessWidget {
final GlobalKey<FormState> formKey;
final TextEditingController nameCtrl;
final TextEditingController emailCtrl;
@@ -27,94 +26,43 @@ class AddressBookRecipientFormBody extends StatefulWidget {
required this.onBack,
});
@override
State<AddressBookRecipientFormBody> createState() => _AddressBookRecipientFormBodyState();
}
class _AddressBookRecipientFormBodyState extends State<AddressBookRecipientFormBody> {
PaymentType? _selectedType;
int? _selectedIndex;
void _reconcileSelection(AddressBookRecipientFormProvider formState) {
final types = formState.supportedTypes;
if (types.isEmpty) return;
var nextType = _selectedType;
var nextIndex = _selectedIndex;
if (nextType == null || !types.contains(nextType)) {
nextType = formState.preferredType ?? types.first;
nextIndex = null;
}
final entries = formState.methods[nextType] ?? const [];
if (entries.isEmpty) {
nextIndex = null;
} else if (nextIndex == null || nextIndex < 0 || nextIndex >= entries.length) {
nextIndex = 0;
}
if (nextType == _selectedType && nextIndex == _selectedIndex) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_selectedType = nextType;
_selectedIndex = nextIndex;
});
});
}
void _onMethodSelected(PaymentType type, int index) {
setState(() {
_selectedType = type;
_selectedIndex = index;
});
}
void _onMethodAdd(AddressBookRecipientFormProvider formState, PaymentType type) {
final newIndex = formState.addMethod(type);
setState(() {
_selectedType = type;
_selectedIndex = newIndex;
});
}
void _onMethodRemove(AddressBookRecipientFormProvider formState, int index) {
final type = _selectedType ?? formState.supportedTypes.first;
formState.removeMethod(type, index);
}
void _onMethodChanged(
AddressBookRecipientFormProvider formState,
int index,
PaymentMethodData data,
) {
final type = _selectedType ?? formState.supportedTypes.first;
formState.updateMethod(type, index, data);
}
@override
Widget build(BuildContext context) {
final formState = context.watch<AddressBookRecipientFormProvider>();
_reconcileSelection(formState);
final formState = Provider.of<AddressBookRecipientFormProvider>(context, listen: false);
final controller = Provider.of<AddressBookRecipientFormController>(context);
final selection =
Provider.of<AddressBookRecipientFormSelectionController>(context);
final selectedType = _selectedType ?? formState.supportedTypes.first;
if (controller.supportedTypes.isEmpty) {
return const SizedBox.shrink();
}
final selectedType = selection.selectedType ?? controller.supportedTypes.first;
return FormView(
formKey: widget.formKey,
nameCtrl: widget.nameCtrl,
emailCtrl: widget.emailCtrl,
types: formState.supportedTypes,
formKey: formKey,
nameCtrl: nameCtrl,
emailCtrl: emailCtrl,
types: controller.supportedTypes,
selectedType: selectedType,
selectedIndex: _selectedIndex,
methods: formState.methods,
onMethodSelected: _onMethodSelected,
onMethodAdd: (type) => _onMethodAdd(formState, type),
selectedIndex: selection.selectedIndex,
methods: controller.methods,
onMethodSelected: selection.select,
onMethodAdd: (type) {
final newIndex = controller.addMethod(type);
selection.selectAfterAdd(type, newIndex);
},
disabledTypes: disabledPaymentTypes,
onMethodRemove: (index) => _onMethodRemove(formState, index),
onMethodChanged: (index, data) => _onMethodChanged(formState, index, data),
onSave: () => widget.onSave(formState),
isEditing: widget.isEditing,
onBack: widget.onBack,
onMethodRemove: (index) {
final type = selection.selectedType ?? controller.supportedTypes.first;
controller.removeMethod(type, index);
},
onMethodChanged: (index, data) {
final type = selection.selectedType ?? controller.supportedTypes.first;
controller.updateMethod(type, index, data);
},
onSave: () => onSave(formState),
isEditing: isEditing,
onBack: onBack,
);
}
}

View File

@@ -7,12 +7,11 @@ import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/provider/recipient/methods_cache.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/controllers/organization/address_book_recipient_form.dart';
import 'package:pweb/controllers/organization/address_book_recipient_form_selection.dart';
import 'package:pweb/pages/address_book/form/body.dart';
import 'package:pweb/providers/address_book_recipient_form.dart';
import 'package:pweb/utils/payment/availability.dart';
import 'package:pweb/utils/payment/label.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class AddressBookRecipientForm extends StatefulWidget {
@@ -29,6 +28,8 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameCtrl;
late TextEditingController _emailCtrl;
late final String _initialName;
late final String _initialEmail;
static const List<PaymentType> _supportedTypes = visiblePaymentTypes;
@@ -36,61 +37,79 @@ class _AddressBookRecipientFormState extends State<AddressBookRecipientForm> {
void initState() {
super.initState();
final r = widget.recipient;
_nameCtrl = TextEditingController(text: r?.name ?? '');
_emailCtrl = TextEditingController(text: r?.email ?? '');
}
Map<PaymentType, String> _methodNames(BuildContext context) => {
for (final type in _supportedTypes) type: getPaymentTypeLabel(context, type),
};
Future<void> _save(AddressBookRecipientFormProvider formState) async {
final l10n = AppLocalizations.of(context)!;
if (!_formKey.currentState!.validate() || !formState.hasAnyMethod) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.recipientFormRule)),
);
return;
}
try {
final saved = await formState.save(
name: _nameCtrl.text,
email: _emailCtrl.text,
methodNames: _methodNames(context),
);
widget.onSaved?.call(saved);
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.notificationError(l10n.noErrorInformation))),
);
}
_initialName = r?.name ?? '';
_initialEmail = r?.email ?? '';
_nameCtrl = TextEditingController(text: _initialName);
_emailCtrl = TextEditingController(text: _initialEmail);
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProxyProvider2<
RecipientsProvider,
RecipientMethodsCacheProvider,
AddressBookRecipientFormProvider
>(
create: (_) => AddressBookRecipientFormProvider(
recipient: widget.recipient,
supportedTypes: _supportedTypes,
),
update: (_, recipientsProvider, methodsCache, formProvider) =>
formProvider!..updateProviders(
recipientsProvider: recipientsProvider,
methodsCache: methodsCache,
return MultiProvider(
providers: [
ChangeNotifierProxyProvider2<
RecipientsProvider,
RecipientMethodsCacheProvider,
AddressBookRecipientFormProvider
>(
create: (_) => AddressBookRecipientFormProvider(
recipient: widget.recipient,
),
child: AddressBookRecipientFormBody(
formKey: _formKey,
nameCtrl: _nameCtrl,
emailCtrl: _emailCtrl,
isEditing: widget.recipient != null,
onSave: _save,
onBack: () => widget.onSaved?.call(null),
update: (_, recipientsProvider, methodsCache, formProvider) =>
formProvider!..updateProviders(
recipientsProvider: recipientsProvider,
methodsCache: methodsCache,
),
),
ChangeNotifierProxyProvider<
RecipientMethodsCacheProvider,
AddressBookRecipientFormController
>(
create: (_) => AddressBookRecipientFormController(
supportedTypes: _supportedTypes,
),
update: (_, methodsCache, controller) => controller!
..update(
recipient: widget.recipient,
methodsCache: methodsCache,
),
),
ChangeNotifierProxyProvider<
AddressBookRecipientFormController,
AddressBookRecipientFormSelectionController
>(
create: (_) => AddressBookRecipientFormSelectionController(),
update: (_, formController, selectionController) =>
selectionController!..update(formController),
),
],
child: Builder(
builder: (context) {
final formState = context.read<AddressBookRecipientFormProvider>();
final controller = context.read<AddressBookRecipientFormController>();
return AddressBookRecipientFormBody(
formKey: _formKey,
nameCtrl: _nameCtrl,
emailCtrl: _emailCtrl,
isEditing: widget.recipient != null,
onSave: (form) => controller.saveForm(
context: context,
formKey: _formKey,
formState: form,
name: _nameCtrl.text,
email: _emailCtrl.text,
onSaved: widget.onSaved,
),
onBack: () => controller.handleBack(
context: context,
formKey: _formKey,
formState: formState,
name: _nameCtrl.text,
email: _emailCtrl.text,
onSaved: widget.onSaved,
),
);
},
),
);
}

View File

@@ -12,7 +12,8 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class AddPaymentMethodButton extends StatelessWidget {
final List<PaymentType> types;
final Set<PaymentType> disabledTypes;
final ValueChanged<PaymentType> onAdd;
final ValueChanged<PaymentType>? onAdd;
final VoidCallback? onPressed;
static const double _borderRadius = 14;
static const double _iconSize = 18;
@@ -20,13 +21,18 @@ class AddPaymentMethodButton extends StatelessWidget {
static const double _menuIconSize = 18;
static const double _menuIconTextSpacing = 8;
static const double _buttonHeight = 70;
static const EdgeInsets _buttonPadding = EdgeInsets.symmetric(horizontal: 14, vertical: 10);
static const EdgeInsets _buttonPadding = EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
);
static const FontWeight _labelWeight = FontWeight.w600;
const AddPaymentMethodButton({
super.key,
required this.types,
required this.disabledTypes,
required this.onAdd,
this.onAdd,
this.onPressed,
});
@override
@@ -41,6 +47,38 @@ class AddPaymentMethodButton extends StatelessWidget {
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withValues(alpha: 0.4);
final buttonChild = Container(
height: _buttonHeight,
padding: _buttonPadding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_borderRadius),
border: Border.all(color: borderColor),
color: theme.colorScheme.onSecondary,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, size: _iconSize, color: textColor),
const SizedBox(width: _iconTextSpacing),
Text(
l10n.addPaymentMethod,
style: theme.textTheme.titleSmall?.copyWith(
color: textColor,
fontWeight: _labelWeight,
),
),
],
),
);
final onPressed = this.onPressed;
if (onPressed != null) {
return GestureDetector(
onTap: hasEnabled ? onPressed : null,
child: buttonChild,
);
}
return PopupMenuButton<PaymentType>(
enabled: hasEnabled,
onSelected: onAdd,
@@ -67,29 +105,7 @@ class AddPaymentMethodButton extends StatelessWidget {
);
})
.toList(),
child: Container(
height: _buttonHeight,
padding: _buttonPadding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_borderRadius),
border: Border.all(color: borderColor),
color: theme.colorScheme.onSecondary,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, size: _iconSize, color: textColor),
const SizedBox(width: _iconTextSpacing),
Text(
l10n.addPaymentMethod,
style: theme.textTheme.titleSmall?.copyWith(
color: textColor,
fontWeight: _labelWeight,
),
),
],
),
),
child: buttonChild,
);
}
}

View File

@@ -7,6 +7,8 @@ import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/pages/payment_methods/form.dart';
import 'package:pweb/pages/payment_methods/icon.dart';
import 'package:pweb/widgets/dialogs/confirmation_dialog.dart';
import 'package:pweb/models/state/control_state.dart';
import 'package:pweb/models/state/visibility.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -17,6 +19,8 @@ class PaymentMethodPanel extends StatelessWidget {
final List<RecipientMethodDraft> entries;
final ValueChanged<int> onRemove;
final void Function(int, PaymentMethodData) onChanged;
final ControlState editState;
final VisibilityState deleteVisibility;
final double padding;
@@ -27,6 +31,8 @@ class PaymentMethodPanel extends StatelessWidget {
required this.entries,
required this.onRemove,
required this.onChanged,
this.editState = ControlState.enabled,
this.deleteVisibility = VisibilityState.visible,
this.padding = 16,
});
@@ -79,7 +85,7 @@ class PaymentMethodPanel extends StatelessWidget {
),
),
),
if (entry != null)
if (entry != null && deleteVisibility == VisibilityState.visible)
TextButton.icon(
onPressed: () => _confirmDelete(context, () => onRemove(selectedIndex)),
icon: Icon(Icons.delete, color: theme.colorScheme.error),
@@ -96,6 +102,7 @@ class PaymentMethodPanel extends StatelessWidget {
key: ValueKey('${selectedType.name}-${entry.existing?.id ?? selectedIndex}-form'),
selectedType: selectedType,
initialData: entry.data,
isEditable: editState == ControlState.enabled,
onChanged: (data) {
if (data == null) return;
onChanged(selectedIndex, data);

View File

@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/models/payment_method_tile/availability.dart';
import 'package:pweb/models/payment_method_tile/selection.dart';
import 'package:pweb/models/payment/method_tile/availability.dart';
import 'package:pweb/models/payment/method_tile/selection.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/add_button.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/tile.dart';
@@ -15,8 +15,10 @@ class PaymentMethodSelectorRow extends StatelessWidget {
final int? selectedIndex;
final Map<PaymentType, List<RecipientMethodDraft>> methods;
final void Function(PaymentType type, int index) onSelected;
final ValueChanged<PaymentType> onAdd;
final ValueChanged<PaymentType>? onAdd;
final VoidCallback? onAddPressed;
final Set<PaymentType> disabledTypes;
final String? Function(RecipientMethodDraft entry)? detailsBuilder;
final double spacing;
final double tilePadding;
@@ -29,8 +31,10 @@ class PaymentMethodSelectorRow extends StatelessWidget {
required this.selectedIndex,
required this.methods,
required this.onSelected,
required this.onAdd,
this.onAdd,
this.onAddPressed,
this.disabledTypes = const {},
this.detailsBuilder,
this.spacing = 12,
this.tilePadding = 10,
this.runSpacing = 12,
@@ -51,12 +55,14 @@ class PaymentMethodSelectorRow extends StatelessWidget {
final availability = isAdded
? PaymentMethodTileAvailability.added
: PaymentMethodTileAvailability.available;
final detailsText = detailsBuilder?.call(entry);
tiles.add(
PaymentMethodTile(
type: type,
selection: selection,
availability: availability,
padding: tilePadding,
detailsText: detailsText,
onTap: () => onSelected(type, index),
),
);
@@ -68,6 +74,7 @@ class PaymentMethodSelectorRow extends StatelessWidget {
types: types,
disabledTypes: disabledTypes,
onAdd: onAdd,
onPressed: onAddPressed,
),
);
@@ -78,4 +85,4 @@ class PaymentMethodSelectorRow extends StatelessWidget {
children: tiles,
);
}
}
}

View File

@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/models/payment_method_tile/availability.dart';
import 'package:pweb/models/payment_method_tile/selection.dart';
import 'package:pweb/models/payment/method_tile/availability.dart';
import 'package:pweb/models/payment/method_tile/selection.dart';
import 'package:pweb/pages/payment_methods/icon.dart';
import 'package:pweb/utils/payment/label.dart';
@@ -15,6 +15,7 @@ class PaymentMethodTile extends StatelessWidget {
final PaymentMethodTileSelection selection;
final PaymentMethodTileAvailability availability;
final double padding;
final String? detailsText;
final VoidCallback? onTap;
const PaymentMethodTile({
@@ -22,6 +23,7 @@ class PaymentMethodTile extends StatelessWidget {
required this.selection,
required this.availability,
required this.padding,
this.detailsText,
required this.onTap,
});
@@ -50,6 +52,13 @@ class PaymentMethodTile extends StatelessWidget {
final backgroundColor = isSelected
? theme.colorScheme.primary.withValues(alpha: 0.08)
: theme.colorScheme.onSecondary;
final showDetails =
availability == PaymentMethodTileAvailability.added &&
detailsText != null &&
detailsText!.isNotEmpty;
final detailsColor = isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant;
return IntrinsicWidth(
child: Opacity(
@@ -68,9 +77,9 @@ class PaymentMethodTile extends StatelessWidget {
border: Border.all(color: borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
iconForPaymentType(type),
@@ -78,30 +87,44 @@ class PaymentMethodTile extends StatelessWidget {
color: isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurface,
),
const SizedBox(width: 8),
Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurface,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: isSelected ? theme.colorScheme.primary : theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
if (showDetails)
Text(
detailsText!,
style: theme.textTheme.labelSmall?.copyWith(
color: detailsColor,
fontWeight: FontWeight.w600,
),
)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: badgeColor,
borderRadius: BorderRadius.circular(999),
),
child: Text(
badgeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: badgeTextColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: badgeColor,
borderRadius: BorderRadius.circular(999),
),
child: Text(
badgeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: badgeTextColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
),

View File

@@ -34,6 +34,8 @@ class RecipientAddressBookInfoRow extends StatelessWidget {
final style = textStyle ?? Theme.of(context).textTheme.bodySmall!;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(iconForPaymentType(type), size: iconSize),
@@ -55,4 +57,4 @@ class RecipientAddressBookInfoRow extends StatelessWidget {
],
);
}
}
}

View File

@@ -59,6 +59,7 @@ class _RecipientAddressBookItemState extends State<RecipientAddressBookItem> {
child: Padding(
padding: widget.padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [

View File

@@ -29,12 +29,22 @@ class RecipientPaymentRow extends StatelessWidget {
return const Center(child: CircularProgressIndicator());
}
return Row(
spacing: spacing,
children: cacheProvider.methodsForRecipient(recipientId).map((m) => RecipientAddressBookInfoRow(
type: m.type,
value: getPaymentTypeDescription(context, m),
)).toList(),
return Align(
alignment: Alignment.centerLeft,
child: Wrap(
alignment: WrapAlignment.start,
runAlignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.start,
spacing: spacing,
runSpacing: spacing,
children: cacheProvider
.methodsForRecipient(recipientId)
.map((m) => RecipientAddressBookInfoRow(
type: m.type,
value: getPaymentTypeDescription(context, m),
))
.toList(),
),
);
}
}

View File

@@ -6,7 +6,7 @@ import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/models/dashboard_payment_mode.dart';
import 'package:pweb/models/dashboard/dashboard_payment_mode.dart';
import 'package:pweb/pages/dashboard/buttons/balance/balance.dart';
import 'package:pweb/pages/dashboard/buttons/balance/controller.dart';
import 'package:pweb/pages/dashboard/buttons/buttons.dart';

View File

@@ -1,81 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentAmountWidget extends StatefulWidget {
const PaymentAmountWidget({super.key});
@override
State<PaymentAmountWidget> createState() => _PaymentAmountWidgetState();
}
class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
late final TextEditingController _controller;
bool _isSyncingText = false;
@override
void initState() {
super.initState();
final initialAmount = context.read<PaymentAmountProvider>().amount;
_controller = TextEditingController(text: amountToString(initialAmount));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
double? _parseAmount(String value) {
final parsed = parseMoneyAmount(
value.replaceAll(',', '.'),
fallback: double.nan,
);
return parsed.isNaN ? null : parsed;
}
void _syncTextWithAmount(double amount) {
final parsedText = _parseAmount(_controller.text);
if (parsedText != null && parsedText == amount) return;
final nextText = amountToString(amount);
_isSyncingText = true;
_controller.value = TextEditingValue(
text: nextText,
selection: TextSelection.collapsed(offset: nextText.length),
);
_isSyncingText = false;
}
void _onChanged(String value) {
if (_isSyncingText) return;
final parsed = _parseAmount(value);
if (parsed != null) {
context.read<PaymentAmountProvider>().setAmount(parsed);
}
}
@override
Widget build(BuildContext context) {
final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount);
_syncTextWithAmount(amount);
return TextField(
controller: _controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.amount,
border: const OutlineInputBorder(),
),
onChanged: _onChanged,
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/controllers/payments/amount_field.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentAmountField extends StatelessWidget {
const PaymentAmountField();
@override
Widget build(BuildContext context) {
final currency = context.select<WalletsController, Currency?>(
(c) => c.selectedWallet?.currency,
);
final symbol = currency == null ? null : currencyCodeToSymbol(currency);
final ui = context.watch<PaymentAmountFieldController>();
return TextField(
controller: ui.textController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.amount,
border: const OutlineInputBorder(),
prefixText: symbol == null ? null : '$symbol\u00A0',
),
onChanged: ui.handleChanged,
);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pweb/controllers/payments/amount_field.dart';
import 'package:pweb/pages/dashboard/payouts/amount/feild.dart';
class PaymentAmountWidget extends StatelessWidget {
const PaymentAmountWidget({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProxyProvider<PaymentAmountProvider, PaymentAmountFieldController>(
create: (ctx) {
final initialAmount = ctx.read<PaymentAmountProvider>().amount;
return PaymentAmountFieldController(initialAmount: initialAmount);
},
update: (ctx, amountProvider, controller) {
controller!.update(amountProvider);
return controller;
},
child: const PaymentAmountField(),
);
}
}

View File

@@ -15,15 +15,30 @@ class FeePayerSwitch extends StatelessWidget {
@override
Widget build(BuildContext context) => Consumer<PaymentAmountProvider>(
builder: (context, provider, _) => Row(
spacing: spacing,
children: [
Text(AppLocalizations.of(context)!.recipientPaysFee, style: style),
Switch(
value: !provider.payerCoversFee,
onChanged: (val) => provider.setPayerCoversFee(!val),
builder: (context, provider, _) {
final recipientPaysFee = !provider.payerCoversFee;
final textStyle = style ?? Theme.of(context).textTheme.bodySmall;
void updateRecipientPaysFee(bool value) {
provider.setPayerCoversFee(!value);
}
return InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () => updateRecipientPaysFee(!recipientPaysFee),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: recipientPaysFee,
onChanged: (val) => updateRecipientPaysFee(val ?? false),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
SizedBox(width: spacing),
Text(AppLocalizations.of(context)!.recipientPaysFee, style: textStyle),
],
),
],
),
);
},
);
}

View File

@@ -1,8 +1,13 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/dashboard/payouts/amount.dart';
import 'package:provider/provider.dart';
import 'package:pweb/controllers/payouts/quotation.dart';
import 'package:pweb/models/dashboard/quote_status_data.dart';
import 'package:pweb/pages/dashboard/payouts/amount/widget.dart';
import 'package:pweb/pages/dashboard/payouts/fee_payer.dart';
import 'package:pweb/pages/dashboard/payouts/quote_status/quote_status.dart';
import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/card.dart';
import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/refresh_section.dart';
import 'package:pweb/pages/dashboard/payouts/summary/widget.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -12,34 +17,134 @@ class PaymentFormWidget extends StatelessWidget {
const PaymentFormWidget({super.key});
static const double _smallSpacing = 5;
static const double _mediumSpacing = 10;
static const double _largeSpacing = 16;
static const double _mediumSpacing = 12;
static const double _largeSpacing = 20;
static const double _extraSpacing = 15;
static const double _columnSpacing = 24;
static const double _narrowWidth = 560;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!;
final controller = context.watch<QuotationController>();
final quoteStatus = QuoteStatusData.resolve(
controller: controller,
loc: loc,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(loc.details, style: theme.textTheme.titleMedium),
const SizedBox(height: _smallSpacing),
return LayoutBuilder(
builder: (context, constraints) {
final isNarrow = constraints.maxWidth < _narrowWidth;
const PaymentAmountWidget(),
final detailsHeader = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(loc.details, style: theme.textTheme.titleMedium),
const SizedBox(height: _smallSpacing),
],
);
const SizedBox(height: _mediumSpacing),
final quoteCard = QuoteStatusCard(
statusType: quoteStatus.statusType,
isLoading: quoteStatus.isLoading,
statusText: quoteStatus.statusText,
helperText: quoteStatus.helperText,
canRefresh: quoteStatus.canRefresh,
showPrimaryRefresh: quoteStatus.showPrimaryRefresh,
onRefresh: controller.refreshQuotation,
);
FeePayerSwitch(spacing: _mediumSpacing, style: theme.textTheme.titleMedium),
final autoRefreshSection = QuoteAutoRefreshSection(
autoRefreshMode: quoteStatus.autoRefreshMode,
canRefresh: quoteStatus.canRefresh,
onModeChanged: controller.setAutoRefreshMode,
);
const SizedBox(height: _largeSpacing),
final leftColumn = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const PaymentAmountWidget(),
const SizedBox(height: _smallSpacing),
FeePayerSwitch(
spacing: _smallSpacing,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: _mediumSpacing),
const PaymentSummary(spacing: _extraSpacing),
],
);
const PaymentSummary(spacing: _extraSpacing),
const SizedBox(height: _mediumSpacing),
const QuoteStatus(spacing: _smallSpacing),
],
final rightColumn = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
quoteCard,
const SizedBox(height: _smallSpacing),
autoRefreshSection,
],
);
if (isNarrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
detailsHeader,
leftColumn,
const SizedBox(height: _largeSpacing),
rightColumn,
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
detailsHeader,
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const PaymentAmountWidget(),
const SizedBox(height: _smallSpacing),
FeePayerSwitch(
spacing: _smallSpacing,
style: theme.textTheme.bodySmall,
),
],
),
),
const SizedBox(width: _columnSpacing),
Expanded(flex: 2, child: quoteCard),
],
),
const SizedBox(height: _mediumSpacing),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Expanded(
flex: 3,
child: PaymentSummary(spacing: _extraSpacing),
),
const SizedBox(width: _columnSpacing),
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
autoRefreshSection,
],
),
),
],
),
],
);
},
);
}
}

View File

@@ -2,8 +2,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:pshared/provider/payment/multiple/quotation.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/payout_verification.dart';
import 'package:pweb/utils/payment/payout_verification_flow.dart';
import 'package:pweb/widgets/dialogs/payment_status_dialog.dart';
@@ -13,9 +15,13 @@ Future<void> handleMultiplePayoutSend(
MultiplePayoutsController controller,
) async {
final verificationController = context.read<PayoutVerificationController>();
final quotationProvider = context.read<MultiQuotationProvider>();
final verificationContextKey = quotationProvider.quotation?.quoteRef ??
quotationProvider.quotation?.idempotencyKey;
final verified = await runPayoutVerification(
context: context,
controller: verificationController,
contextKey: verificationContextKey,
);
if (!verified) return;

View File

@@ -3,7 +3,7 @@ import 'package:pshared/models/money.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
String moneyLabel(Money? money) {
@@ -32,8 +32,5 @@ String sentAmountLabel(MultiplePayoutsController controller) {
}
String feeLabel(MultiplePayoutsController controller) {
final feeLabelText = moneyLabel(controller.aggregateFeeAmount);
final percent = controller.aggregateFeePercent;
if (percent == null) return feeLabelText;
return '$feeLabelText (${percent.toStringAsFixed(2)}%)';
return moneyLabel(controller.aggregateFeeAmount);
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/models/summary_values.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/models/dashboard/summary_values.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart';
import 'package:pweb/pages/dashboard/payouts/summary/widget.dart';
@@ -21,7 +21,6 @@ class SourceQuoteSummary extends StatelessWidget {
return PaymentSummary(
spacing: spacing,
values: PaymentSummaryValues(
sentAmount: sentAmountLabel(controller),
fee: feeLabel(controller),
recipientReceives: moneyLabel(controller.aggregateSettlementAmount),
total: moneyLabel(controller.aggregateDebitAmount),

View File

@@ -1,19 +1,20 @@
import 'package:flutter/material.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:provider/provider.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/controllers/payout_verification.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/provider/payment/multiple/quotation.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/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/pages/payout_page/send/widgets/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:provider/provider.dart';
import 'package:pweb/models/state/control_state.dart';
class SourceQuotePanel extends StatelessWidget {
@@ -31,7 +32,12 @@ class SourceQuotePanel extends StatelessWidget {
final theme = Theme.of(context);
final verificationController =
context.watch<PayoutVerificationController>();
final isCooldownActive = verificationController.isCooldownActive;
final quotationProvider = context.watch<MultiQuotationProvider>();
final verificationContextKey = quotationProvider.quotation?.quoteRef ??
quotationProvider.quotation?.idempotencyKey;
final isCooldownActive = verificationController.isCooldownActiveFor(
verificationContextKey,
);
final canSend = controller.canSend && !isCooldownActive;
return Container(
width: double.infinity,
@@ -72,7 +78,9 @@ class SourceQuotePanel extends StatelessWidget {
if (isCooldownActive) ...[
const SizedBox(height: 8),
CooldownHint(
seconds: verificationController.cooldownRemainingSeconds,
seconds: verificationController.cooldownRemainingSecondsFor(
verificationContextKey,
),
),
],
],

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:dotted_border/dotted_border.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/drop_zone.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/actions.dart';

View File

@@ -6,7 +6,7 @@ import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/header.dart';
import 'package:pweb/controllers/recent_payments.dart';
import 'package:pweb/controllers/payments/recent_payments.dart';
import 'package:pweb/pages/report/cards/column.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
import 'package:pweb/app/router/payout_routes.dart';

View File

@@ -1,4 +1,4 @@
import 'package:pweb/models/multiple_payouts/csv_row.dart';
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
const String sampleFileName = 'sample.csv';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:pweb/models/multiple_payouts/csv_row.dart';
import 'package:pweb/models/payment/multiple_payouts/csv_row.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/widget.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/panel_card.dart';

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/header.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart';

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/payment/quote/status_type.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/card.dart';
import 'package:pweb/utils/quote_duration_format.dart';

View File

@@ -48,34 +48,14 @@ class QuoteAutoRefreshSection extends StatelessWidget {
),
),
const SizedBox(width: _autoRefreshSpacing),
ToggleButtons(
isSelected: [
autoRefreshMode == AutoRefreshMode.off,
autoRefreshMode == AutoRefreshMode.on,
],
onPressed: canRefresh
? (index) {
final nextMode =
index == 1 ? AutoRefreshMode.on : AutoRefreshMode.off;
if (nextMode == autoRefreshMode) return;
onModeChanged(nextMode);
}
Switch.adaptive(
activeTrackColor: theme.colorScheme.primary,
value: autoRefreshMode == AutoRefreshMode.on,
onChanged: canRefresh
? (value) => onModeChanged(
value ? AutoRefreshMode.on : AutoRefreshMode.off,
)
: null,
borderRadius: BorderRadius.circular(999),
constraints: const BoxConstraints(minHeight: 32, minWidth: 56),
selectedColor: theme.colorScheme.onPrimary,
fillColor: theme.colorScheme.primary,
color: theme.colorScheme.onSurface,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(loc.toggleOff),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(loc.toggleOn),
),
],
),
],
);

View File

@@ -26,6 +26,7 @@ class RecipientAvatar extends StatelessWidget {
final textColor = Theme.of(context).colorScheme.onPrimary;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: avatarRadius,

View File

@@ -7,11 +7,13 @@ import 'package:pweb/pages/dashboard/payouts/single/address_book/avatar.dart';
class ShortListAddressBookPayout extends StatelessWidget {
final List<Recipient> recipients;
final ValueChanged<Recipient> onSelected;
final Widget? trailing;
const ShortListAddressBookPayout({
super.key,
required this.recipients,
required this.onSelected,
this.trailing,
});
static const double _avatarRadius = 20;
@@ -21,10 +23,13 @@ class ShortListAddressBookPayout extends StatelessWidget {
@override
Widget build(BuildContext context) {
final trailingWidget = trailing;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: recipients.map((recipient) {
children:
recipients.map((recipient) {
return Padding(
padding: _padding,
child: InkWell(
@@ -44,8 +49,13 @@ class ShortListAddressBookPayout extends StatelessWidget {
),
),
);
}).toList(),
}).toList()
..addAll(
trailingWidget == null
? const []
: [Padding(padding: _padding, child: trailingWidget)],
),
),
);
}
}
}

View File

@@ -1,26 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/asset.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pweb/pages/dashboard/payouts/summary/row.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentSentAmountRow extends StatelessWidget {
final Currency currency;
const PaymentSentAmountRow({super.key, required this.currency});
@override
Widget build(BuildContext context) => Consumer<PaymentAmountProvider>(
builder: (context, provider, _) => PaymentSummaryRow(
labelFactory: AppLocalizations.of(context)!.sentAmount,
asset: Asset(currency: currency, amount: provider.amount),
style: Theme.of(context).textTheme.titleMedium,
),
);
}

View File

@@ -1,15 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/models/summary_values.dart';
import 'package:pweb/models/dashboard/summary_values.dart';
import 'package:pweb/pages/dashboard/payouts/summary/fee.dart';
import 'package:pweb/pages/dashboard/payouts/summary/recipient_receives.dart';
import 'package:pweb/pages/dashboard/payouts/summary/row.dart';
import 'package:pweb/pages/dashboard/payouts/summary/sent_amount.dart';
import 'package:pweb/pages/dashboard/payouts/summary/total.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -36,12 +30,6 @@ class PaymentSummary extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PaymentSummaryRow(
labelFactory: loc.sentAmount,
asset: null,
value: resolvedValues.sentAmount,
style: theme.textTheme.titleMedium,
),
PaymentSummaryRow(
labelFactory: loc.fee,
asset: null,
@@ -73,12 +61,6 @@ class PaymentSummary extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PaymentSentAmountRow(
currency: currencyStringToCode(
context.read<WalletsController>().selectedWallet?.tokenSymbol ??
'USDT',
),
),
const PaymentFeeRow(),
const PaymentRecipientReceivesRow(),
SizedBox(height: spacing),

View File

@@ -1,164 +0,0 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/resources.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/invitations.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pweb/pages/invitations/widgets/header.dart';
import 'package:pweb/pages/invitations/widgets/form/form.dart';
import 'package:pweb/pages/invitations/widgets/list/list.dart';
import 'package:pweb/pages/loader.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/widgets/roles/create_role_dialog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationsPage extends StatefulWidget {
const InvitationsPage({super.key});
@override
State<InvitationsPage> createState() => _InvitationsPageState();
}
class _InvitationsPageState extends State<InvitationsPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _firstNameController = TextEditingController();
final TextEditingController _lastNameController = TextEditingController();
final TextEditingController _messageController = TextEditingController();
String? _selectedRoleRef;
int _expiryDays = 7;
Future<void> _createRole() async {
final loc = AppLocalizations.of(context)!;
final draft = await showCreateRoleDialog(context);
if (draft == null) return;
final permissions = context.read<PermissionsProvider>();
final createdRole = await executeActionWithNotification(
context: context,
action: () => permissions.createRoleDescription(
name: draft.name,
description: draft.description.isEmpty ? null : draft.description,
),
successMessage: loc.invitationRoleCreated,
errorMessage: loc.invitationRoleCreateFailed,
);
if (createdRole != null && mounted) {
setState(() => _selectedRoleRef = createdRole.id);
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_bootstrapRoleSelection();
}
void _bootstrapRoleSelection() {
final roles = context.read<PermissionsProvider>().roleDescriptions;
if (roles.isEmpty) return;
final firstRoleRef = roles.first.storable.id;
final isSelectedAvailable = _selectedRoleRef != null
&& roles.any((role) => role.storable.id == _selectedRoleRef);
if (isSelectedAvailable) return;
if (!mounted) return;
setState(() => _selectedRoleRef = firstRoleRef);
}
@override
void dispose() {
_emailController.dispose();
_firstNameController.dispose();
_lastNameController.dispose();
_messageController.dispose();
super.dispose();
}
Future<void> _sendInvitation() async {
final form = _formKey.currentState;
if (form == null || !form.validate()) return;
final account = context.read<AccountProvider>().account;
if (account == null) return;
final permissions = context.read<PermissionsProvider>();
final roleRef = _selectedRoleRef ?? permissions.roleDescriptions.firstOrNull?.storable.id;
if (roleRef == null) return;
final invitations = context.read<InvitationsProvider>();
final loc = AppLocalizations.of(context)!;
await executeActionWithNotification(
context: context,
action: () => invitations.sendInvitation(
email: _emailController.text.trim(),
name: _firstNameController.text.trim(),
lastName: _lastNameController.text.trim(),
comment: _messageController.text.trim(),
roleRef: roleRef,
inviterRef: account.id,
expiresAt: DateTime.now().toUtc().add(Duration(days: _expiryDays)),
),
successMessage: loc.invitationCreatedSuccess,
errorMessage: loc.errorCreatingInvitation,
);
_emailController.clear();
_firstNameController.clear();
_lastNameController.clear();
_messageController.clear();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final permissions = context.watch<PermissionsProvider>();
final canCreateRoles = permissions.canCreate(ResourceType.roles);
if (!permissions.canRead(ResourceType.invitations)) {
return PageViewLoader(
child: Center(child: Text(loc.errorAccessDenied)),
);
}
return PageViewLoader(
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InvitationsHeader(loc: loc),
const SizedBox(height: 16),
InvitationsForm(
formKey: _formKey,
emailController: _emailController,
firstNameController: _firstNameController,
lastNameController: _lastNameController,
messageController: _messageController,
canCreateRoles: canCreateRoles,
onCreateRole: _createRole,
expiryDays: _expiryDays,
onExpiryChanged: (value) => setState(() => _expiryDays = value),
selectedRoleRef: _selectedRoleRef,
onRoleChanged: (role) => setState(() => _selectedRoleRef = role),
canCreate: permissions.canCreate(ResourceType.invitations),
onSubmit: _sendInvitation,
),
const SizedBox(height: 24),
const InvitationsList(),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/controllers/invitations/page.dart';
import 'package:pweb/pages/invitations/page/providers.dart';
import 'package:pweb/pages/invitations/page/view.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationsPage extends StatefulWidget {
const InvitationsPage({super.key});
@override
State<InvitationsPage> createState() => _InvitationsPageState();
}
class _InvitationsPageState extends State<InvitationsPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _firstNameController = TextEditingController();
final TextEditingController _lastNameController = TextEditingController();
final TextEditingController _messageController = TextEditingController();
Future<void> _sendInvitation(BuildContext context) async {
final form = _formKey.currentState;
if (form == null || !form.validate()) return;
final loc = AppLocalizations.of(context)!;
await executeActionWithNotification(
context: context,
action: () => context.read<InvitationsPageController>().sendInvitation(
email: _emailController.text,
name: _firstNameController.text,
lastName: _lastNameController.text,
comment: _messageController.text,
),
successMessage: loc.invitationCreatedSuccess,
errorMessage: loc.errorCreatingInvitation,
);
_emailController.clear();
_firstNameController.clear();
_lastNameController.clear();
_messageController.clear();
}
@override
void dispose() {
_emailController.dispose();
_firstNameController.dispose();
_lastNameController.dispose();
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return InvitationsPageProviders(
child: Builder(
builder: (context) => InvitationsPageView(
formKey: _formKey,
emailController: _emailController,
firstNameController: _firstNameController,
lastNameController: _lastNameController,
messageController: _messageController,
onSubmit: () => _sendInvitation(context),
),
),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/invitations.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pweb/controllers/invitations/page.dart';
class InvitationsPageProviders extends StatelessWidget {
final Widget child;
const InvitationsPageProviders({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) {
return ChangeNotifierProxyProvider3<
PermissionsProvider,
InvitationsProvider,
AccountProvider,
InvitationsPageController
>(
create: (_) => InvitationsPageController(),
update: (_, permissions, invitations, account, controller) => controller!
..update(
permissions: permissions,
invitations: invitations,
account: account,
),
child: child,
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/resources.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pweb/controllers/invitations/page.dart';
import 'package:pweb/pages/invitations/widgets/header.dart';
import 'package:pweb/pages/invitations/widgets/form/form.dart';
import 'package:pweb/pages/invitations/widgets/list/list.dart';
import 'package:pweb/pages/loader.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationsPageView extends StatelessWidget {
final GlobalKey<FormState> formKey;
final TextEditingController emailController;
final TextEditingController firstNameController;
final TextEditingController lastNameController;
final TextEditingController messageController;
final VoidCallback onSubmit;
const InvitationsPageView({
super.key,
required this.formKey,
required this.emailController,
required this.firstNameController,
required this.lastNameController,
required this.messageController,
required this.onSubmit,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final permissions = context.watch<PermissionsProvider>();
final canCreateRoles = permissions.canCreate(ResourceType.roles);
final ui = context.watch<InvitationsPageController>();
if (!permissions.canRead(ResourceType.invitations)) {
return PageViewLoader(
child: Center(child: Text(loc.errorAccessDenied)),
);
}
return PageViewLoader(
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InvitationsHeader(loc: loc),
const SizedBox(height: 16),
InvitationsForm(
formKey: formKey,
emailController: emailController,
firstNameController: firstNameController,
lastNameController: lastNameController,
messageController: messageController,
canCreateRoles: canCreateRoles,
expiryDays: ui.expiryDays,
onExpiryChanged: ui.setExpiryDays,
selectedRoleRef: ui.selectedRoleRef,
onRoleChanged: ui.setSelectedRoleRef,
canCreate: permissions.canCreate(ResourceType.invitations),
onSubmit: onSubmit,
),
const SizedBox(height: 24),
const InvitationsList(),
],
),
),
),
);
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:pweb/models/invitation_filter.dart';
import 'package:pweb/models/invitation/invitation_filter.dart';
import 'package:pweb/pages/invitations/widgets/filter/invitation_filter.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -1,7 +1,7 @@
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/models/invitation/status.dart';
import 'package:pweb/models/invitation_filter.dart';
import 'package:pweb/models/invitation/invitation_filter.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';

View File

@@ -10,7 +10,6 @@ class InvitationsForm extends StatelessWidget {
final TextEditingController lastNameController;
final TextEditingController messageController;
final bool canCreateRoles;
final VoidCallback onCreateRole;
final int expiryDays;
final ValueChanged<int> onExpiryChanged;
final String? selectedRoleRef;
@@ -26,7 +25,6 @@ class InvitationsForm extends StatelessWidget {
required this.lastNameController,
required this.messageController,
required this.canCreateRoles,
required this.onCreateRole,
required this.expiryDays,
required this.onExpiryChanged,
required this.selectedRoleRef,
@@ -43,7 +41,6 @@ class InvitationsForm extends StatelessWidget {
lastNameController: lastNameController,
messageController: messageController,
canCreateRoles: canCreateRoles,
onCreateRole: onCreateRole,
expiryDays: expiryDays,
onExpiryChanged: onExpiryChanged,
selectedRoleRef: selectedRoleRef,

View File

@@ -17,7 +17,6 @@ class InvitationFormView extends StatelessWidget {
final TextEditingController lastNameController;
final TextEditingController messageController;
final bool canCreateRoles;
final VoidCallback onCreateRole;
final int expiryDays;
final ValueChanged<int> onExpiryChanged;
final String? selectedRoleRef;
@@ -33,7 +32,6 @@ class InvitationFormView extends StatelessWidget {
required this.lastNameController,
required this.messageController,
required this.canCreateRoles,
required this.onCreateRole,
required this.expiryDays,
required this.onExpiryChanged,
required this.selectedRoleRef,

View File

@@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
import 'package:pshared/provider/invitations.dart';
import 'package:pweb/models/invitation_filter.dart';
import 'package:pweb/models/invitation/invitation_filter.dart';
import 'package:pweb/pages/invitations/widgets/filter/chips.dart';
import 'package:pweb/pages/invitations/widgets/list/body.dart';
import 'package:pweb/pages/invitations/widgets/list/view_model.dart';

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pweb/models/invitation_filter.dart';
import 'package:pweb/models/invitation/invitation_filter.dart';
import 'package:pweb/pages/invitations/widgets/filter/invitation_filter.dart';

View File

@@ -6,7 +6,9 @@ import 'package:pshared/models/auth/state.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/app/router/pages.dart';
import 'package:pweb/controllers/auth/account_loader.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/models/account/account_loader.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -20,29 +22,29 @@ class AccountLoader extends StatefulWidget {
}
class _AccountLoaderState extends State<AccountLoader> {
AuthState? _handledState;
late final AccountLoaderController _controller;
@override
void initState() {
super.initState();
_controller = AccountLoaderController()..addListener(_handleControllerAction);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
Provider.of<AccountProvider>(context, listen: false).restoreIfPossible();
});
}
void _handleSideEffects(AccountProvider provider) {
if (_handledState == provider.authState) return;
_handledState = provider.authState;
void _handleControllerAction() {
final action = _controller.consumeAction();
if (action == null) return;
void goToLogin() {
if (!mounted) return;
navigateAndReplace(context, Pages.login);
}
switch (provider.authState) {
case AuthState.error:
final error = provider.error ?? Exception('Authorization failed');
switch (action) {
case AccountLoaderAction.showErrorAndGoToLogin:
final error = _controller.error ?? Exception('Authorization failed');
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
postNotifyUserOfErrorX(
@@ -53,18 +55,23 @@ class _AccountLoaderState extends State<AccountLoader> {
goToLogin();
});
break;
case AuthState.empty:
case AccountLoaderAction.goToLogin:
WidgetsBinding.instance.addPostFrameCallback((_) => goToLogin());
break;
default:
break;
}
}
@override
void dispose() {
_controller.removeListener(_handleControllerAction);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Consumer<AccountProvider>(builder: (context, provider, _) {
_handleSideEffects(provider);
_controller.update(provider);
if (provider.authState == AuthState.ready && provider.account != null) {
return widget.child;
}

View File

@@ -32,9 +32,6 @@ class OrganizationLoader extends StatelessWidget {
);
}
if ((provider.error == null) && (!provider.isOrganizationSet)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
provider.load();
});
return const Center(child: CircularProgressIndicator());
}
return child;

View File

@@ -12,16 +12,6 @@ class PermissionsLoader extends StatelessWidget {
final Widget child;
const PermissionsLoader({super.key, required this.child});
void _triggerLoadIfNeeded(PermissionsProvider provider) {
if (!provider.isLoading && !provider.isReady) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!provider.isLoading && !provider.isReady) {
provider.load();
}
});
}
}
@override
Widget build(BuildContext context) {
return Consumer2<PermissionsProvider, AccountProvider>(
@@ -42,7 +32,6 @@ class PermissionsLoader extends StatelessWidget {
),
);
}
_triggerLoadIfNeeded(provider);
if (provider.isLoading || !provider.isReady) {
return const Center(child: CircularProgressIndicator());
}

View File

@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -15,9 +13,8 @@ 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/controllers/auth/email.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/services/posthog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -57,7 +54,6 @@ class _LoginFormState extends State<LoginForm> {
password: _passwordController.text,
locale: context.read<LocaleProvider>().locale.languageCode,
);
unawaited(PosthogService.login(pending: outcome.isPending));
if (outcome.isPending) {
// TODO: fix context usage
navigateAndReplace(context, Pages.sfactor);

Some files were not shown because too many files have changed in this diff Show More