redesigned payment page + a lot of fixes
This commit is contained in:
63
frontend/pweb/lib/controllers/auth/account_loader.dart
Normal file
63
frontend/pweb/lib/controllers/auth/account_loader.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
143
frontend/pweb/lib/controllers/auth/account_name.dart
Normal file
143
frontend/pweb/lib/controllers/auth/account_name.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/provider/account.dart';
|
||||
|
||||
import 'package:pweb/models/state/edit_state.dart';
|
||||
|
||||
|
||||
class AccountNameController extends ChangeNotifier {
|
||||
AccountNameController({
|
||||
required this.initialFirstName,
|
||||
required this.initialLastName,
|
||||
required this.errorMessage,
|
||||
}) {
|
||||
_firstNameController = TextEditingController(text: initialFirstName);
|
||||
_lastNameController = TextEditingController(text: initialLastName);
|
||||
_lastSyncedFirstName = initialFirstName;
|
||||
_lastSyncedLastName = initialLastName;
|
||||
}
|
||||
|
||||
AccountProvider? _accountProvider;
|
||||
final String initialFirstName;
|
||||
final String initialLastName;
|
||||
final String errorMessage;
|
||||
|
||||
late final TextEditingController _firstNameController;
|
||||
late final TextEditingController _lastNameController;
|
||||
EditState _editState = EditState.view;
|
||||
String _errorText = '';
|
||||
bool _disposed = false;
|
||||
String _lastSyncedFirstName = '';
|
||||
String _lastSyncedLastName = '';
|
||||
|
||||
TextEditingController get firstNameController => _firstNameController;
|
||||
TextEditingController get lastNameController => _lastNameController;
|
||||
EditState get editState => _editState;
|
||||
String get errorText => _errorText;
|
||||
bool get isEditing => _editState != EditState.view;
|
||||
bool get isSaving => _editState == EditState.saving;
|
||||
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();
|
||||
if (first.isEmpty && last.isEmpty) return '';
|
||||
if (first.isEmpty) return last;
|
||||
if (last.isEmpty) return first;
|
||||
return '$first $last';
|
||||
}
|
||||
|
||||
void update(AccountProvider accountProvider) {
|
||||
_accountProvider = accountProvider;
|
||||
final changed = _syncNamesFromProvider();
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void startEditing() => _setState(EditState.edit);
|
||||
|
||||
void cancelEditing() {
|
||||
_firstNameController.text = currentFirstName;
|
||||
_lastNameController.text = currentLastName;
|
||||
_setError('');
|
||||
_setState(EditState.view);
|
||||
}
|
||||
|
||||
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)) {
|
||||
cancelEditing();
|
||||
return false;
|
||||
}
|
||||
|
||||
_setError('');
|
||||
_setState(EditState.saving);
|
||||
|
||||
try {
|
||||
await accountProvider.resetUsername(
|
||||
newFirstName,
|
||||
lastName: newLastName,
|
||||
);
|
||||
_setState(EditState.view);
|
||||
return true;
|
||||
} catch (_) {
|
||||
_setError(errorMessage);
|
||||
_setState(EditState.edit);
|
||||
return false;
|
||||
} finally {
|
||||
if (_editState == EditState.saving) {
|
||||
_setState(EditState.edit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setError(String value) {
|
||||
if (_disposed) return;
|
||||
_errorText = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
103
frontend/pweb/lib/controllers/auth/password_form.dart
Normal file
103
frontend/pweb/lib/controllers/auth/password_form.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/provider/account.dart';
|
||||
import 'package:pshared/api/responses/error/server.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 PasswordFormController extends ChangeNotifier {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
final oldPasswordController = TextEditingController();
|
||||
final newPasswordController = TextEditingController();
|
||||
final confirmPasswordController = TextEditingController();
|
||||
|
||||
final Map<PasswordFieldType, VisibilityState> _visibility = {
|
||||
PasswordFieldType.old: VisibilityState.hidden,
|
||||
PasswordFieldType.newPassword: VisibilityState.hidden,
|
||||
PasswordFieldType.confirmPassword: VisibilityState.hidden,
|
||||
};
|
||||
EditState _state = EditState.view;
|
||||
String _errorText = '';
|
||||
bool _disposed = false;
|
||||
|
||||
bool get isExpanded => _state != EditState.view;
|
||||
bool get isSaving => _state == EditState.saving;
|
||||
String get errorText => _errorText;
|
||||
EditState get state => _state;
|
||||
bool isPasswordVisible(PasswordFieldType type) =>
|
||||
_visibility[type] == VisibilityState.visible;
|
||||
|
||||
void toggleExpanded() {
|
||||
if (_state == EditState.saving) return;
|
||||
_setState(_state == EditState.view ? EditState.edit : EditState.view);
|
||||
_setError('');
|
||||
}
|
||||
|
||||
void togglePasswordVisibility(PasswordFieldType type) {
|
||||
final current = _visibility[type];
|
||||
if (current == null) return;
|
||||
_visibility[type] = current == VisibilityState.hidden
|
||||
? VisibilityState.visible
|
||||
: VisibilityState.hidden;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> submit({
|
||||
required AccountProvider accountProvider,
|
||||
required String errorText,
|
||||
}) async {
|
||||
final currentForm = formKey.currentState;
|
||||
if (currentForm == null || !currentForm.validate()) return false;
|
||||
|
||||
_setState(EditState.saving);
|
||||
_setError('');
|
||||
|
||||
try {
|
||||
await accountProvider.changePassword(
|
||||
oldPasswordController.text,
|
||||
newPasswordController.text,
|
||||
);
|
||||
|
||||
oldPasswordController.clear();
|
||||
newPasswordController.clear();
|
||||
confirmPasswordController.clear();
|
||||
_setState(EditState.view);
|
||||
return true;
|
||||
} catch (e) {
|
||||
_setError(_errorMessageForException(e, errorText));
|
||||
_setState(EditState.edit);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
String _errorMessageForException(Object exception, String fallback) {
|
||||
if (exception is ErrorResponse && exception.details.isNotEmpty) {
|
||||
return exception.details;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
void _setState(EditState value) {
|
||||
if (_state == value || _disposed) return;
|
||||
_state = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setError(String value) {
|
||||
if (_disposed) return;
|
||||
_errorText = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
oldPasswordController.dispose();
|
||||
newPasswordController.dispose();
|
||||
confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
85
frontend/pweb/lib/controllers/common/cooldown.dart
Normal file
85
frontend/pweb/lib/controllers/common/cooldown.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
111
frontend/pweb/lib/controllers/invitations/page.dart
Normal file
111
frontend/pweb/lib/controllers/invitations/page.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
67
frontend/pweb/lib/controllers/payments/amount_field.dart
Normal file
67
frontend/pweb/lib/controllers/payments/amount_field.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
43
frontend/pweb/lib/controllers/payments/page_ui.dart
Normal file
43
frontend/pweb/lib/controllers/payments/page_ui.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
55
frontend/pweb/lib/controllers/payments/payment_config.dart
Normal file
55
frontend/pweb/lib/controllers/payments/payment_config.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/payment/methods/data.dart';
|
||||
import 'package:pshared/models/payment/methods/type.dart';
|
||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||
|
||||
import 'package:pweb/pages/payment_methods/add/widget.dart';
|
||||
import 'package:pweb/widgets/dialogs/confirmation_dialog.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentConfigController {
|
||||
final BuildContext context;
|
||||
|
||||
PaymentConfigController(this.context);
|
||||
|
||||
Future<void> addMethod() async => showDialog<PaymentMethodData>(
|
||||
context: context,
|
||||
builder: (_) => const AddPaymentMethodDialog(),
|
||||
);
|
||||
|
||||
Future<void> editMethod(PaymentMethod method) async {
|
||||
// TODO: implement edit functionality
|
||||
}
|
||||
|
||||
Future<void> deleteMethod(PaymentMethod method) async {
|
||||
final methodsProvider = context.read<PaymentMethodsProvider>();
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final confirmed = await showConfirmationDialog(
|
||||
context: context,
|
||||
title: l10n.delete,
|
||||
message: l10n.deletePaymentConfirmation,
|
||||
confirmLabel: l10n.delete,
|
||||
);
|
||||
if (confirmed) {
|
||||
methodsProvider.delete(method.id);
|
||||
}
|
||||
}
|
||||
|
||||
void toggleEnabled(PaymentMethod method, bool value) {
|
||||
context.read<PaymentMethodsProvider>().setArchivedMethod(method: method, newIsArchived: value);
|
||||
}
|
||||
|
||||
void makeMain(PaymentMethod method) {
|
||||
context.read<PaymentMethodsProvider>().makeMain(method);
|
||||
}
|
||||
|
||||
void reorder(int oldIndex, int newIndex) {
|
||||
// TODO: rimplement on top of Indexable
|
||||
// context.read<PaymentMethodsProvider>().reorderMethods(oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
104
frontend/pweb/lib/controllers/payouts/quotation.dart
Normal file
104
frontend/pweb/lib/controllers/payouts/quotation.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
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';
|
||||
|
||||
|
||||
class QuotationController extends ChangeNotifier {
|
||||
QuotationProvider? _quotation;
|
||||
Timer? _ticker;
|
||||
|
||||
void update(QuotationProvider quotation) {
|
||||
if (identical(_quotation, quotation)) return;
|
||||
_quotation?.removeListener(_handleQuotationChanged);
|
||||
_quotation = quotation;
|
||||
_quotation?.addListener(_handleQuotationChanged);
|
||||
_handleQuotationChanged();
|
||||
}
|
||||
|
||||
bool get isLoading => _quotation?.isLoading ?? false;
|
||||
Exception? get error => _quotation?.error;
|
||||
bool get canRefresh => _quotation?.canRefresh ?? false;
|
||||
bool get isReady => _quotation?.isReady ?? false;
|
||||
AutoRefreshMode get autoRefreshMode =>
|
||||
_quotation?.autoRefreshMode ?? AutoRefreshMode.on;
|
||||
|
||||
DateTime? get quoteExpiresAt => _quotation?.quoteExpiresAt;
|
||||
|
||||
Duration? get timeLeft {
|
||||
final expiresAt = quoteExpiresAt;
|
||||
if (expiresAt == null) return null;
|
||||
return expiresAt.difference(DateTime.now().toUtc());
|
||||
}
|
||||
|
||||
bool get isExpired {
|
||||
final remaining = timeLeft;
|
||||
if (remaining == null) return false;
|
||||
return remaining <= Duration.zero;
|
||||
}
|
||||
|
||||
QuoteStatusType get quoteStatus {
|
||||
if (isLoading) return QuoteStatusType.loading;
|
||||
if (error != null) return QuoteStatusType.error;
|
||||
if (_quotation?.quotation == null) return QuoteStatusType.missing;
|
||||
if (isExpired) return QuoteStatusType.expired;
|
||||
return QuoteStatusType.active;
|
||||
}
|
||||
|
||||
bool get hasLiveQuote => isReady && _quotation?.quotation != null && !isExpired;
|
||||
|
||||
void setAutoRefreshMode(AutoRefreshMode mode) {
|
||||
_quotation?.setAutoRefreshMode(mode);
|
||||
}
|
||||
|
||||
void refreshQuotation() {
|
||||
_quotation?.refreshQuotation();
|
||||
}
|
||||
|
||||
void _handleQuotationChanged() {
|
||||
_syncTicker();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _syncTicker() {
|
||||
final expiresAt = quoteExpiresAt;
|
||||
if (expiresAt == null) {
|
||||
_stopTicker();
|
||||
return;
|
||||
}
|
||||
|
||||
final remaining = expiresAt.difference(DateTime.now().toUtc());
|
||||
if (remaining <= Duration.zero) {
|
||||
_stopTicker();
|
||||
return;
|
||||
}
|
||||
|
||||
_ticker ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
final expiresAt = quoteExpiresAt;
|
||||
if (expiresAt == null) {
|
||||
_stopTicker();
|
||||
return;
|
||||
}
|
||||
final remaining = expiresAt.difference(DateTime.now().toUtc());
|
||||
if (remaining <= Duration.zero) {
|
||||
_stopTicker();
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void _stopTicker() {
|
||||
_ticker?.cancel();
|
||||
_ticker = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_quotation?.removeListener(_handleQuotationChanged);
|
||||
_stopTicker();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:pshared/models/auth/probe_result.dart';
|
||||
import 'package:pshared/provider/account.dart';
|
||||
|
||||
|
||||
class SignupConfirmationController extends ChangeNotifier {
|
||||
SignupConfirmationController({
|
||||
required AccountProvider accountProvider,
|
||||
Duration pollInterval = const Duration(seconds: 10),
|
||||
}) : _accountProvider = accountProvider,
|
||||
_pollInterval = pollInterval;
|
||||
|
||||
final AccountProvider _accountProvider;
|
||||
final Duration _pollInterval;
|
||||
|
||||
Timer? _pollTimer;
|
||||
bool _isChecking = false;
|
||||
bool _isAuthorized = false;
|
||||
|
||||
String? _email;
|
||||
String? _password;
|
||||
String? _locale;
|
||||
|
||||
bool get isAuthorized => _isAuthorized;
|
||||
bool get isChecking => _isChecking;
|
||||
|
||||
void startPolling({
|
||||
required String email,
|
||||
required String password,
|
||||
required String locale,
|
||||
}) {
|
||||
final trimmedEmail = email.trim();
|
||||
final trimmedPassword = password.trim();
|
||||
final trimmedLocale = locale.trim();
|
||||
if (trimmedEmail.isEmpty || trimmedPassword.isEmpty || trimmedLocale.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_email = trimmedEmail;
|
||||
_password = trimmedPassword;
|
||||
_locale = trimmedLocale;
|
||||
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = Timer.periodic(_pollInterval, (_) => _probeAuthorization());
|
||||
_probeAuthorization();
|
||||
}
|
||||
|
||||
void stopPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _probeAuthorization() async {
|
||||
if (_isChecking || _isAuthorized) return;
|
||||
final email = _email;
|
||||
final password = _password;
|
||||
final locale = _locale;
|
||||
if (email == null || password == null || locale == null) return;
|
||||
|
||||
_setChecking(true);
|
||||
try {
|
||||
final result = await _accountProvider.probeAuthorization(
|
||||
email: email,
|
||||
password: password,
|
||||
locale: locale,
|
||||
);
|
||||
if (result == AuthProbeResult.authorized) {
|
||||
_isAuthorized = true;
|
||||
stopPolling();
|
||||
notifyListeners();
|
||||
}
|
||||
} finally {
|
||||
_setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _setChecking(bool value) {
|
||||
if (_isChecking == value) return;
|
||||
_isChecking = value;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user