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

@@ -0,0 +1,37 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/provider/payment/multiple/quotation.dart';
class MultiQuotationController extends ChangeNotifier {
MultiQuotationProvider? _quotation;
void update(MultiQuotationProvider 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;
DateTime? get quoteExpiresAt => _quotation?.quoteExpiresAt;
void refreshQuotation() {
_quotation?.refreshQuotation();
}
void _handleQuotationChanged() {
notifyListeners();
}
@override
void dispose() {
_quotation?.removeListener(_handleQuotationChanged);
super.dispose();
}
}

View File

@@ -0,0 +1,160 @@
import 'package:flutter/foundation.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/money.dart';
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/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';
class MultiplePayoutsController extends ChangeNotifier {
final CsvInputService _csvInput;
MultiplePayoutsProvider? _provider;
WalletsController? _wallets;
_PickState _pickState = _PickState.idle;
Exception? _uiError;
MultiplePayoutsController({
required CsvInputService csvInput,
}) : _csvInput = csvInput;
void update(MultiplePayoutsProvider provider, WalletsController wallets) {
var shouldNotify = false;
if (!identical(_provider, provider)) {
_provider?.removeListener(_onProviderChanged);
_provider = provider;
_provider?.addListener(_onProviderChanged);
shouldNotify = true;
}
if (!identical(_wallets, wallets)) {
_wallets?.removeListener(_onWalletsChanged);
_wallets = wallets;
_wallets?.addListener(_onWalletsChanged);
shouldNotify = true;
}
if (shouldNotify) {
notifyListeners();
}
}
MultiplePayoutsState get state =>
_provider?.state ?? MultiplePayoutsState.idle;
String? get selectedFileName => _provider?.selectedFileName;
List<CsvPayoutRow> get rows => _provider?.rows ?? const <CsvPayoutRow>[];
int get sentCount => _provider?.sentCount ?? 0;
Exception? get error => _uiError ?? _provider?.error;
bool get isQuoting => _provider?.isQuoting ?? false;
bool get isSending => _provider?.isSending ?? false;
bool get isBusy => _provider?.isBusy ?? false;
bool get quoteIsLoading => _provider?.quoteIsLoading ?? false;
QuoteStatusType get quoteStatusType =>
_provider?.quoteStatusType ?? QuoteStatusType.missing;
Duration? get quoteTimeLeft => _provider?.quoteTimeLeft;
bool get canSend => _provider?.canSend ?? false;
Money? get aggregateDebitAmount =>
_provider?.aggregateDebitAmountFor(_selectedWallet);
Money? get requestedSentAmount => _provider?.requestedSentAmount;
Money? get aggregateSettlementAmount =>
_provider?.aggregateSettlementAmountFor(_selectedWallet);
Money? get aggregateFeeAmount =>
_provider?.aggregateFeeAmountFor(_selectedWallet);
double? get aggregateFeePercent =>
_provider?.aggregateFeePercentFor(_selectedWallet);
Future<void> pickAndQuote() async {
if (_pickState == _PickState.picking) return;
final provider = _provider;
if (provider == null) {
_setUiError(StateError('Multiple payouts provider is not ready'));
return;
}
_clearUiError();
_pickState = _PickState.picking;
try {
final picked = await _csvInput.pickCsv();
if (picked == null) return;
final wallet = _selectedWallet;
if (wallet == null) {
_setUiError(StateError('Select source wallet first'));
return;
}
await provider.quoteFromCsv(
fileName: picked.name,
content: picked.content,
sourceWallet: wallet,
);
} catch (e) {
_setUiError(e);
} finally {
_pickState = _PickState.idle;
}
}
Future<List<Payment>> send() async {
return _provider?.send() ?? const <Payment>[];
}
Future<MultiplePayoutSendOutcome> sendAndGetOutcome() async {
_clearUiError();
final provider = _provider;
if (provider == null) {
_setUiError(StateError('Multiple payouts provider is not ready'));
return MultiplePayoutSendOutcome.failure;
}
final payments = await provider.send();
final hasError = provider.error != null;
if (hasError || payments.isEmpty) {
return MultiplePayoutSendOutcome.failure;
}
return MultiplePayoutSendOutcome.success;
}
void removeUploadedFile() {
_provider?.removeUploadedFile();
_clearUiError(notify: false);
}
void _onProviderChanged() {
notifyListeners();
}
void _onWalletsChanged() {
notifyListeners();
}
Wallet? get _selectedWallet => _wallets?.selectedWallet;
void _setUiError(Object error) {
_uiError = error is Exception ? error : Exception(error.toString());
notifyListeners();
}
void _clearUiError({bool notify = true}) {
if (_uiError == null) return;
_uiError = null;
if (notify) {
notifyListeners();
}
}
@override
void dispose() {
_provider?.removeListener(_onProviderChanged);
_wallets?.removeListener(_onWalletsChanged);
super.dispose();
}
}
enum _PickState { idle, picking }
enum MultiplePayoutSendOutcome { success, failure }

View File

@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:pshared/provider/payout_verification.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;
late final CooldownController _cooldown;
String? _contextKey;
String? _cooldownContextKey;
FlowStatus get status => _status;
bool get isSubmitting => _status == FlowStatus.submitting;
bool get isResending => _status == FlowStatus.resending;
bool get hasError => _status == FlowStatus.error;
bool get verificationSuccess => _status == FlowStatus.success;
Object? get error => _error;
String get target => _provider?.target ?? '';
int get cooldownRemainingSeconds => _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;
_provider?.removeListener(_onProviderChanged);
_provider = provider;
_provider?.addListener(_onProviderChanged);
_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 {
await provider.requestCode();
_setStatus(FlowStatus.idle);
} catch (e) {
_error = e;
_setStatus(FlowStatus.error);
rethrow;
}
}
Future<void> submitCode(String code) async {
final provider = _provider;
if (provider == null) {
throw StateError('Payout verification provider is not ready');
}
_error = null;
_setStatus(FlowStatus.submitting);
try {
await provider.confirmCode(code);
_setStatus(FlowStatus.success);
} catch (e) {
_error = e;
_setStatus(FlowStatus.error);
}
}
Future<void> resendCode() async {
final provider = _provider;
if (provider == null) {
throw StateError('Payout verification provider is not ready');
}
if (isResending || isCooldownActive) return;
_bindCooldownContext();
_error = null;
_setStatus(FlowStatus.resending);
try {
await provider.resendCode();
_setStatus(FlowStatus.idle);
} catch (e) {
_error = e;
_setStatus(FlowStatus.error);
}
}
void reset() {
_error = null;
_setStatus(FlowStatus.idle);
_cooldown.stop();
_cooldownContextKey = null;
_contextKey = null;
_provider?.reset();
}
void resetStatus() {
_error = null;
_setStatus(FlowStatus.idle);
}
void _onProviderChanged() {
_syncCooldown(_provider?.cooldownUntil);
notifyListeners();
}
void _syncCooldown(DateTime? until) {
if (_cooldownContextKey == null || _cooldownContextKey != _contextKey) {
_cooldown.stop(notify: _cooldown.isActive);
return;
}
if (until == null) {
_cooldown.stop(notify: _cooldown.isActive);
return;
}
if (!_isCooldownActive(until)) {
_cooldown.stop(notify: _cooldown.isActive);
return;
}
final currentUntil = _cooldown.until;
if (currentUntil == null || !currentUntil.isAtSameMomentAs(until)) {
_cooldown.syncUntil(until, notify: true);
}
}
bool _isCooldownActive(DateTime until) => until.isAfter(DateTime.now());
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) {
if (_status == status) return;
_status = status;
notifyListeners();
}
@override
void dispose() {
_provider?.removeListener(_onProviderChanged);
_cooldown.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
class PayoutVolumesController extends ChangeNotifier {
DateTimeRange _range;
PayoutVolumesController({DateTime? now})
: _range = _defaultRange(now ?? DateTime.now());
DateTimeRange get range => _range;
void setRange(DateTimeRange range) {
final normalized = _normalizeRange(range);
if (_isSameRange(_range, normalized)) return;
_range = normalized;
notifyListeners();
}
static DateTimeRange _defaultRange(DateTime now) {
final local = now.toLocal();
final start = DateTime(local.year, local.month, 1);
final end = DateTime(
local.year,
local.month,
local.day,
);
return DateTimeRange(start: start, end: end);
}
static DateTimeRange _normalizeRange(DateTimeRange range) {
final start = DateTime(range.start.year, range.start.month, range.start.day);
final end = DateTime(
range.end.year,
range.end.month,
range.end.day,
);
return DateTimeRange(start: start, end: end);
}
static bool _isSameRange(DateTimeRange a, DateTimeRange b) {
return a.start.isAtSameMomentAs(b.start) &&
a.end.isAtSameMomentAs(b.end);
}
}

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