diff --git a/frontend/pshared/lib/data/dto/payment/operation.dart b/frontend/pshared/lib/data/dto/payment/operation.dart new file mode 100644 index 00000000..8a2365c4 --- /dev/null +++ b/frontend/pshared/lib/data/dto/payment/operation.dart @@ -0,0 +1,56 @@ +class PaymentOperationDTO { + final String? stepRef; + final String? operationRef; + final String? code; + final String? state; + final String? label; + final String? failureCode; + final String? failureReason; + final String? startedAt; + final String? completedAt; + + const PaymentOperationDTO({ + this.stepRef, + this.operationRef, + this.code, + this.state, + this.label, + this.failureCode, + this.failureReason, + this.startedAt, + this.completedAt, + }); + + factory PaymentOperationDTO.fromJson(Map json) => + PaymentOperationDTO( + stepRef: _asString(json['stepRef'] ?? json['step_ref']), + operationRef: _asString(json['operationRef'] ?? json['operation_ref']), + code: _asString(json['code']), + state: _asString(json['state']), + label: _asString(json['label']), + failureCode: _asString(json['failureCode'] ?? json['failure_code']), + failureReason: _asString( + json['failureReason'] ?? json['failure_reason'], + ), + startedAt: _asString(json['startedAt'] ?? json['started_at']), + completedAt: _asString(json['completedAt'] ?? json['completed_at']), + ); + + Map toJson() => { + 'stepRef': stepRef, + 'operationRef': operationRef, + 'code': code, + 'state': state, + 'label': label, + 'failureCode': failureCode, + 'failureReason': failureReason, + 'startedAt': startedAt, + 'completedAt': completedAt, + }; +} + +String? _asString(Object? value) { + final text = value?.toString().trim(); + if (text == null || text.isEmpty) return null; + return text; +} diff --git a/frontend/pshared/lib/data/dto/payment/payment.dart b/frontend/pshared/lib/data/dto/payment/payment.dart index 8cb38541..c3f4cddd 100644 --- a/frontend/pshared/lib/data/dto/payment/payment.dart +++ b/frontend/pshared/lib/data/dto/payment/payment.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import 'package:pshared/data/dto/payment/operation.dart'; import 'package:pshared/data/dto/payment/payment_quote.dart'; part 'payment.g.dart'; @@ -12,6 +13,8 @@ class PaymentDTO { final String? state; final String? failureCode; final String? failureReason; + @JsonKey(defaultValue: []) + final List operations; final PaymentQuoteDTO? lastQuote; final Map? metadata; final String? createdAt; @@ -22,6 +25,7 @@ class PaymentDTO { this.state, this.failureCode, this.failureReason, + this.operations = const [], this.lastQuote, this.metadata, this.createdAt, diff --git a/frontend/pshared/lib/data/mapper/payment/operation.dart b/frontend/pshared/lib/data/mapper/payment/operation.dart new file mode 100644 index 00000000..5114d0c0 --- /dev/null +++ b/frontend/pshared/lib/data/mapper/payment/operation.dart @@ -0,0 +1,37 @@ +import 'package:pshared/data/dto/payment/operation.dart'; +import 'package:pshared/models/payment/execution_operation.dart'; + + +extension PaymentOperationDTOMapper on PaymentOperationDTO { + PaymentExecutionOperation toDomain() => PaymentExecutionOperation( + stepRef: stepRef, + operationRef: operationRef, + code: code, + state: state, + label: label, + failureCode: failureCode, + failureReason: failureReason, + startedAt: _parseDateTime(startedAt), + completedAt: _parseDateTime(completedAt), + ); +} + +extension PaymentExecutionOperationMapper on PaymentExecutionOperation { + PaymentOperationDTO toDTO() => PaymentOperationDTO( + stepRef: stepRef, + operationRef: operationRef, + code: code, + state: state, + label: label, + failureCode: failureCode, + failureReason: failureReason, + startedAt: startedAt?.toUtc().toIso8601String(), + completedAt: completedAt?.toUtc().toIso8601String(), + ); +} + +DateTime? _parseDateTime(String? value) { + final normalized = value?.trim(); + if (normalized == null || normalized.isEmpty) return null; + return DateTime.tryParse(normalized); +} diff --git a/frontend/pshared/lib/data/mapper/payment/payment_response.dart b/frontend/pshared/lib/data/mapper/payment/payment_response.dart index f8f5b5e9..d79fa17f 100644 --- a/frontend/pshared/lib/data/mapper/payment/payment_response.dart +++ b/frontend/pshared/lib/data/mapper/payment/payment_response.dart @@ -1,8 +1,10 @@ import 'package:pshared/data/dto/payment/payment.dart'; +import 'package:pshared/data/mapper/payment/operation.dart'; import 'package:pshared/data/mapper/payment/quote.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/state.dart'; + extension PaymentDTOMapper on PaymentDTO { Payment toDomain() => Payment( paymentRef: paymentRef, @@ -11,6 +13,7 @@ extension PaymentDTOMapper on PaymentDTO { orchestrationState: paymentOrchestrationStateFromValue(state), failureCode: failureCode, failureReason: failureReason, + operations: operations.map((item) => item.toDomain()).toList(), lastQuote: lastQuote?.toDomain(), metadata: metadata, createdAt: createdAt == null ? null : DateTime.tryParse(createdAt!), @@ -24,6 +27,7 @@ extension PaymentMapper on Payment { state: state ?? paymentOrchestrationStateToValue(orchestrationState), failureCode: failureCode, failureReason: failureReason, + operations: operations.map((item) => item.toDTO()).toList(), lastQuote: lastQuote?.toDTO(), metadata: metadata, createdAt: createdAt?.toUtc().toIso8601String(), diff --git a/frontend/pshared/lib/l10n/en.arb b/frontend/pshared/lib/l10n/en.arb index 21b64145..be98af88 100644 --- a/frontend/pshared/lib/l10n/en.arb +++ b/frontend/pshared/lib/l10n/en.arb @@ -10,11 +10,31 @@ "@operationStatusProcessing": { "description": "Label for the “processing” operation status" }, + "operationStatusPending": "Pending", + "@operationStatusPending": { + "description": "Label for the “pending” operation status" + }, + "operationStatusRetrying": "Retrying", + "@operationStatusRetrying": { + "description": "Label for the “retrying” operation status" + }, "operationStatusSuccess": "Success", "@operationStatusSuccess": { "description": "Label for the “success” operation status" }, + "operationStatusSkipped": "Skipped", + "@operationStatusSkipped": { + "description": "Label for the “skipped” operation status" + }, + "operationStatusCancelled": "Cancelled", + "@operationStatusCancelled": { + "description": "Label for the “cancelled” operation status" + }, + "operationStatusNeedsAttention": "Needs attention", + "@operationStatusNeedsAttention": { + "description": "Label for the “needs attention” operation status" + }, "operationStatusError": "Error", "@operationStatusError": { diff --git a/frontend/pshared/lib/l10n/ru.arb b/frontend/pshared/lib/l10n/ru.arb index ce6efa49..4ebbbc41 100644 --- a/frontend/pshared/lib/l10n/ru.arb +++ b/frontend/pshared/lib/l10n/ru.arb @@ -10,11 +10,31 @@ "@operationStatusProcessing": { "description": "Label for the “processing” operation status" }, + "operationStatusPending": "В ожидании", + "@operationStatusPending": { + "description": "Label for the “pending” operation status" + }, + "operationStatusRetrying": "Повтор", + "@operationStatusRetrying": { + "description": "Label for the “retrying” operation status" + }, "operationStatusSuccess": "Успех", "@operationStatusSuccess": { "description": "Label for the “success” operation status" }, + "operationStatusSkipped": "Пропущен", + "@operationStatusSkipped": { + "description": "Label for the “skipped” operation status" + }, + "operationStatusCancelled": "Отменен", + "@operationStatusCancelled": { + "description": "Label for the “cancelled” operation status" + }, + "operationStatusNeedsAttention": "Требует внимания", + "@operationStatusNeedsAttention": { + "description": "Label for the “needs attention” operation status" + }, "operationStatusError": "Ошибка", "@operationStatusError": { diff --git a/frontend/pshared/lib/models/payment/execution_operation.dart b/frontend/pshared/lib/models/payment/execution_operation.dart new file mode 100644 index 00000000..a2ef8b74 --- /dev/null +++ b/frontend/pshared/lib/models/payment/execution_operation.dart @@ -0,0 +1,23 @@ +class PaymentExecutionOperation { + final String? stepRef; + final String? operationRef; + final String? code; + final String? state; + final String? label; + final String? failureCode; + final String? failureReason; + final DateTime? startedAt; + final DateTime? completedAt; + + const PaymentExecutionOperation({ + required this.stepRef, + required this.operationRef, + required this.code, + required this.state, + required this.label, + required this.failureCode, + required this.failureReason, + required this.startedAt, + required this.completedAt, + }); +} diff --git a/frontend/pshared/lib/models/payment/payment.dart b/frontend/pshared/lib/models/payment/payment.dart index 97e4d99e..90dc2156 100644 --- a/frontend/pshared/lib/models/payment/payment.dart +++ b/frontend/pshared/lib/models/payment/payment.dart @@ -1,3 +1,4 @@ +import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/quote/quote.dart'; import 'package:pshared/models/payment/state.dart'; @@ -8,6 +9,7 @@ class Payment { final PaymentOrchestrationState orchestrationState; final String? failureCode; final String? failureReason; + final List operations; final PaymentQuote? lastQuote; final Map? metadata; final DateTime? createdAt; @@ -19,6 +21,7 @@ class Payment { required this.orchestrationState, required this.failureCode, required this.failureReason, + required this.operations, required this.lastQuote, required this.metadata, required this.createdAt, diff --git a/frontend/pshared/lib/models/payment/status.dart b/frontend/pshared/lib/models/payment/status.dart index e1665e21..d293d00e 100644 --- a/frontend/pshared/lib/models/payment/status.dart +++ b/frontend/pshared/lib/models/payment/status.dart @@ -2,24 +2,35 @@ import 'package:flutter/widgets.dart'; import 'package:pshared/generated/i18n/ps_localizations.dart'; - enum OperationStatus { + pending, processing, + retrying, success, + skipped, + cancelled, + needsAttention, error, } - extension OperationStatusX on OperationStatus { - /// Returns the localized string for this status, - /// e.g. “Processing”, “Success”, “Error”. String localized(BuildContext context) { final loc = PSLocalizations.of(context)!; switch (this) { + case OperationStatus.pending: + return loc.operationStatusPending; case OperationStatus.processing: return loc.operationStatusProcessing; + case OperationStatus.retrying: + return loc.operationStatusRetrying; case OperationStatus.success: return loc.operationStatusSuccess; + case OperationStatus.skipped: + return loc.operationStatusSkipped; + case OperationStatus.cancelled: + return loc.operationStatusCancelled; + case OperationStatus.needsAttention: + return loc.operationStatusNeedsAttention; case OperationStatus.error: return loc.operationStatusError; } diff --git a/frontend/pshared/lib/service/payment/documents.dart b/frontend/pshared/lib/service/payment/documents.dart index 8394ebf3..8408927c 100644 --- a/frontend/pshared/lib/service/payment/documents.dart +++ b/frontend/pshared/lib/service/payment/documents.dart @@ -4,17 +4,30 @@ import 'package:pshared/models/file/downloaded_file.dart'; import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/services.dart'; - class PaymentDocumentsService { static final _logger = Logger('service.payment_documents'); static const String _objectType = Services.payments; - static Future getAct(String organizationRef, String paymentRef) async { - final encodedRef = Uri.encodeQueryComponent(paymentRef); - final url = '/documents/act/$organizationRef?payment_ref=$encodedRef'; + static Future getAct( + String organizationRef, + String paymentRef, { + String? operationRef, + }) async { + final query = {'payment_ref': paymentRef}; + final operationRefValue = operationRef; + if (operationRefValue != null && operationRefValue.isNotEmpty) { + query['operation_ref'] = operationRefValue; + query['operationRef'] = operationRefValue; + } + final queryString = Uri(queryParameters: query).query; + final url = '/documents/act/$organizationRef?$queryString'; _logger.fine('Downloading act document for payment $paymentRef'); - final response = await AuthorizationService.getGETBinaryResponse(_objectType, url); - final filename = _filenameFromDisposition(response.header('content-disposition')) ?? + final response = await AuthorizationService.getGETBinaryResponse( + _objectType, + url, + ); + final filename = + _filenameFromDisposition(response.header('content-disposition')) ?? 'act_$paymentRef.pdf'; final mimeType = response.header('content-type') ?? 'application/pdf'; return DownloadedFile( diff --git a/frontend/pshared/test/payment/payment_state_model_test.dart b/frontend/pshared/test/payment/payment_state_model_test.dart index f461951a..5797458d 100644 --- a/frontend/pshared/test/payment/payment_state_model_test.dart +++ b/frontend/pshared/test/payment/payment_state_model_test.dart @@ -69,6 +69,7 @@ void main() { orchestrationState: PaymentOrchestrationState.created, failureCode: null, failureReason: null, + operations: [], lastQuote: null, metadata: null, createdAt: null, @@ -80,6 +81,7 @@ void main() { orchestrationState: PaymentOrchestrationState.settled, failureCode: null, failureReason: null, + operations: [], lastQuote: null, metadata: null, createdAt: null, @@ -99,6 +101,7 @@ void main() { orchestrationState: PaymentOrchestrationState.executing, failureCode: 'failure_ledger', failureReason: 'ledger failed', + operations: [], lastQuote: null, metadata: null, createdAt: null, @@ -110,6 +113,7 @@ void main() { orchestrationState: PaymentOrchestrationState.failed, failureCode: null, failureReason: null, + operations: [], lastQuote: null, metadata: null, createdAt: null, diff --git a/frontend/pweb/lib/app/router/payout_shell.dart b/frontend/pweb/lib/app/router/payout_shell.dart index 76baec6b..e9cc92c6 100644 --- a/frontend/pweb/lib/app/router/payout_shell.dart +++ b/frontend/pweb/lib/app/router/payout_shell.dart @@ -205,13 +205,13 @@ RouteBase payoutShellRoute() => ShellRoute( ), ChangeNotifierProxyProvider2< MultiplePayoutsProvider, - WalletsController, + PaymentSourceController, MultiplePayoutsController >( create: (_) => MultiplePayoutsController(csvInput: WebCsvInputService()), - update: (context, provider, wallets, controller) => - controller!..update(provider, wallets), + update: (context, provider, sourceController, controller) => + controller!..update(provider, sourceController), ), ], child: PageSelector(child: child, routerState: state), diff --git a/frontend/pweb/lib/controllers/operations/report_operations.dart b/frontend/pweb/lib/controllers/operations/report_operations.dart index 1e3f5841..0ac5f1fe 100644 --- a/frontend/pweb/lib/controllers/operations/report_operations.dart +++ b/frontend/pweb/lib/controllers/operations/report_operations.dart @@ -7,7 +7,7 @@ import 'package:pshared/models/payment/status.dart'; import 'package:pshared/provider/payment/payments.dart'; import 'package:pweb/models/state/load_more_state.dart'; -import 'package:pweb/utils/report/operations.dart'; +import 'package:pweb/utils/report/operations/operations.dart'; import 'package:pweb/utils/report/payment_mapper.dart'; diff --git a/frontend/pweb/lib/controllers/payments/details.dart b/frontend/pweb/lib/controllers/payments/details.dart index 33f8b2d8..06d1e7ce 100644 --- a/frontend/pweb/lib/controllers/payments/details.dart +++ b/frontend/pweb/lib/controllers/payments/details.dart @@ -1,15 +1,18 @@ import 'package:flutter/foundation.dart'; +import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/status.dart'; import 'package:pshared/provider/payment/payments.dart'; +import 'package:pweb/models/documents/operation.dart'; +import 'package:pweb/utils/payment/operation_code.dart'; import 'package:pweb/utils/report/payment_mapper.dart'; class PaymentDetailsController extends ChangeNotifier { PaymentDetailsController({required String paymentId}) - : _paymentId = paymentId; + : _paymentId = paymentId; PaymentsProvider? _payments; String _paymentId; @@ -25,10 +28,34 @@ class PaymentDetailsController extends ChangeNotifier { if (current == null) return false; final status = statusFromPayment(current); final paymentRef = current.paymentRef ?? ''; - return status == OperationStatus.success && - paymentRef.trim().isNotEmpty; + return status == OperationStatus.success && paymentRef.trim().isNotEmpty; } + OperationDocumentRequestModel? operationDocumentRequest( + PaymentExecutionOperation operation, + ) { + final current = _payment; + if (current == null) return null; + + final paymentRef = current.paymentRef?.trim() ?? ''; + if (paymentRef.isEmpty) return null; + + final operationRef = operation.operationRef; + if (operationRef == null || operationRef.isEmpty) return null; + + final pair = parseOperationCodePair(operation.code); + if (pair == null) return null; + if (pair.operation != 'card_payout' || pair.action != 'send') return null; + + return OperationDocumentRequestModel( + paymentRef: paymentRef, + operationRef: operationRef, + ); + } + + bool canDownloadOperationDocument(PaymentExecutionOperation operation) => + operationDocumentRequest(operation) != null; + void update(PaymentsProvider provider, String paymentId) { if (_paymentId != paymentId) { _paymentId = paymentId; diff --git a/frontend/pweb/lib/controllers/payments/recent_payments.dart b/frontend/pweb/lib/controllers/payments/recent_payments.dart index 72497a65..e5b6770f 100644 --- a/frontend/pweb/lib/controllers/payments/recent_payments.dart +++ b/frontend/pweb/lib/controllers/payments/recent_payments.dart @@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:pshared/models/payment/operation.dart'; import 'package:pshared/provider/payment/payments.dart'; -import 'package:pweb/utils/report/operations.dart'; +import 'package:pweb/utils/report/operations/operations.dart'; import 'package:pweb/utils/report/payment_mapper.dart'; diff --git a/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart b/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart index c857af08..439df064 100644 --- a/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart +++ b/frontend/pweb/lib/controllers/payouts/multiple_payouts.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/models/money.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/models/payment/quote/status_type.dart'; @@ -15,15 +15,17 @@ import 'package:pweb/services/payments/csv_input.dart'; class MultiplePayoutsController extends ChangeNotifier { final CsvInputService _csvInput; MultiplePayoutsProvider? _provider; - WalletsController? _wallets; + PaymentSourceController? _sourceController; _PickState _pickState = _PickState.idle; Exception? _uiError; - MultiplePayoutsController({ - required CsvInputService csvInput, - }) : _csvInput = csvInput; + MultiplePayoutsController({required CsvInputService csvInput}) + : _csvInput = csvInput; - void update(MultiplePayoutsProvider provider, WalletsController wallets) { + void update( + MultiplePayoutsProvider provider, + PaymentSourceController sourceController, + ) { var shouldNotify = false; if (!identical(_provider, provider)) { _provider?.removeListener(_onProviderChanged); @@ -31,10 +33,10 @@ class MultiplePayoutsController extends ChangeNotifier { _provider?.addListener(_onProviderChanged); shouldNotify = true; } - if (!identical(_wallets, wallets)) { - _wallets?.removeListener(_onWalletsChanged); - _wallets = wallets; - _wallets?.addListener(_onWalletsChanged); + if (!identical(_sourceController, sourceController)) { + _sourceController?.removeListener(_onSourceChanged); + _sourceController = sourceController; + _sourceController?.addListener(_onSourceChanged); shouldNotify = true; } if (shouldNotify) { @@ -58,7 +60,7 @@ class MultiplePayoutsController extends ChangeNotifier { _provider?.quoteStatusType ?? QuoteStatusType.missing; Duration? get quoteTimeLeft => _provider?.quoteTimeLeft; - bool get canSend => _provider?.canSend ?? false; + bool get canSend => (_provider?.canSend ?? false) && _selectedWallet != null; Money? get aggregateDebitAmount => _provider?.aggregateDebitAmountFor(_selectedWallet); Money? get requestedSentAmount => _provider?.requestedSentAmount; @@ -128,11 +130,11 @@ class MultiplePayoutsController extends ChangeNotifier { notifyListeners(); } - void _onWalletsChanged() { + void _onSourceChanged() { notifyListeners(); } - Wallet? get _selectedWallet => _wallets?.selectedWallet; + Wallet? get _selectedWallet => _sourceController?.selectedWallet; void _setUiError(Object error) { _uiError = error is Exception ? error : Exception(error.toString()); @@ -150,7 +152,7 @@ class MultiplePayoutsController extends ChangeNotifier { @override void dispose() { _provider?.removeListener(_onProviderChanged); - _wallets?.removeListener(_onWalletsChanged); + _sourceController?.removeListener(_onSourceChanged); super.dispose(); } } diff --git a/frontend/pweb/lib/l10n/en.arb b/frontend/pweb/lib/l10n/en.arb index 81a72e58..6c37a488 100644 --- a/frontend/pweb/lib/l10n/en.arb +++ b/frontend/pweb/lib/l10n/en.arb @@ -403,6 +403,34 @@ "idempotencyKeyLabel": "Idempotency key", "quoteIdLabel": "Quote ID", "createdAtLabel": "Created at", + "completedAtLabel": "Completed at", + "operationStepStateSkipped": "Skipped", + "operationStepStateNeedsAttention": "Needs attention", + "operationStepStateRetrying": "Retrying", + "paymentOperationPair": "{operation} {action}", + "@paymentOperationPair": { + "description": "Title pattern for one payment execution operation line in payment details", + "placeholders": { + "operation": { + "type": "String" + }, + "action": { + "type": "String" + } + } + }, + "paymentOperationCardPayout": "Card payout", + "paymentOperationCrypto": "Crypto", + "paymentOperationSettlement": "Settlement", + "paymentOperationLedger": "Ledger", + "paymentOperationActionSend": "Send", + "paymentOperationActionObserve": "Observe", + "paymentOperationActionFxConvert": "FX convert", + "paymentOperationActionCredit": "Credit", + "paymentOperationActionBlock": "Block", + "paymentOperationActionDebit": "Debit", + "paymentOperationActionRelease": "Release", + "paymentOperationActionMove": "Move", "debitAmountLabel": "You pay", "debitSettlementAmountLabel": "Debit settlement amount", "expectedSettlementAmountLabel": "Recipient gets", diff --git a/frontend/pweb/lib/l10n/ru.arb b/frontend/pweb/lib/l10n/ru.arb index 91a447dd..a1777eb1 100644 --- a/frontend/pweb/lib/l10n/ru.arb +++ b/frontend/pweb/lib/l10n/ru.arb @@ -403,6 +403,34 @@ "idempotencyKeyLabel": "Ключ идемпотентности", "quoteIdLabel": "ID котировки", "createdAtLabel": "Создан", + "completedAtLabel": "Завершено", + "operationStepStateSkipped": "Пропущен", + "operationStepStateNeedsAttention": "Требует внимания", + "operationStepStateRetrying": "Повтор", + "paymentOperationPair": "{operation} {action}", + "@paymentOperationPair": { + "description": "Шаблон заголовка строки шага выполнения платежа в деталях платежа", + "placeholders": { + "operation": { + "type": "String" + }, + "action": { + "type": "String" + } + } + }, + "paymentOperationCardPayout": "Выплата на карту", + "paymentOperationCrypto": "Крипто", + "paymentOperationSettlement": "Расчётный контур", + "paymentOperationLedger": "Леджер", + "paymentOperationActionSend": "Отправка", + "paymentOperationActionObserve": "Проверка", + "paymentOperationActionFxConvert": "FX-конверсия", + "paymentOperationActionCredit": "Зачисление", + "paymentOperationActionBlock": "Блокировка", + "paymentOperationActionDebit": "Списание", + "paymentOperationActionRelease": "Разблокировка", + "paymentOperationActionMove": "Перемещение", "debitAmountLabel": "Вы платите", "debitSettlementAmountLabel": "Списано к зачислению", "expectedSettlementAmountLabel": "Получателю поступит", diff --git a/frontend/pweb/lib/models/documents/operation.dart b/frontend/pweb/lib/models/documents/operation.dart new file mode 100644 index 00000000..6fbc1547 --- /dev/null +++ b/frontend/pweb/lib/models/documents/operation.dart @@ -0,0 +1,9 @@ +class OperationDocumentRequestModel { + final String paymentRef; + final String operationRef; + + const OperationDocumentRequestModel({ + required this.paymentRef, + required this.operationRef, + }); +} \ No newline at end of file diff --git a/frontend/pweb/lib/models/payment/source_funds.dart b/frontend/pweb/lib/models/payment/source_funds.dart new file mode 100644 index 00000000..2120f972 --- /dev/null +++ b/frontend/pweb/lib/models/payment/source_funds.dart @@ -0,0 +1,6 @@ +enum SourceOfFundsVisibleState { + headerAction, + summary, + quoteStatus, + sendAction, +} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart index 6f352655..55602f4c 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/card.dart @@ -13,8 +13,6 @@ import 'package:pweb/pages/dashboard/buttons/balance/config.dart'; import 'package:pweb/pages/dashboard/buttons/balance/header.dart'; import 'package:pweb/widgets/refresh_balance/wallet.dart'; -import 'package:pweb/generated/i18n/app_localizations.dart'; - class WalletCard extends StatelessWidget { final Wallet wallet; @@ -30,7 +28,6 @@ class WalletCard extends StatelessWidget { @override Widget build(BuildContext context) { - final loc = AppLocalizations.of(context)!; final networkLabel = (wallet.network == null || wallet.network == ChainNetwork.unspecified) ? null : wallet.network!.localizedName(context); @@ -53,11 +50,12 @@ class WalletCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ BalanceHeader( - title: loc.paymentTypeCryptoWallet, + title: wallet.name, subtitle: networkLabel, badge: (symbol == null || symbol.isEmpty) ? null : symbol, ), Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ BalanceAmount( wallet: wallet, @@ -65,12 +63,16 @@ class WalletCard extends StatelessWidget { context.read().toggleBalanceMask(wallet.id); }, ), - WalletBalanceRefreshButton( - walletRef: wallet.id, + Column( + children: [ + WalletBalanceRefreshButton( + walletRef: wallet.id, + ), + BalanceAddFunds(onTopUp: onTopUp), + ], ), ], ), - BalanceAddFunds(onTopUp: onTopUp), ], ), ), diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/header.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/header.dart deleted file mode 100644 index 67e3294b..00000000 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/header.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:pweb/generated/i18n/app_localizations.dart'; - - -class SourceQuotePanelHeader extends StatelessWidget { - const SourceQuotePanelHeader({ - super.key, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final l10n = AppLocalizations.of(context)!; - return Text( - l10n.sourceOfFunds, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ); - } -} \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart index fd58b055..ced0e633 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/panels/source_quote/widget.dart @@ -2,93 +2,134 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; +import 'package:pshared/controllers/payment/source.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/models/payment/source_funds.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/payout_page/send/widgets/send_button.dart'; +import 'package:pweb/widgets/payment/source_of_funds_panel.dart'; import 'package:pweb/widgets/payment/source_wallet_selector.dart'; import 'package:pweb/widgets/cooldown_hint.dart'; +import 'package:pweb/widgets/refresh_balance/wallet.dart'; import 'package:pweb/models/state/control_state.dart'; +import 'package:pweb/generated/i18n/app_localizations.dart'; + class SourceQuotePanel extends StatelessWidget { - const SourceQuotePanel({ - super.key, - required this.controller, - required this.walletsController, - }); + const SourceQuotePanel({super.key, required this.controller}); final MultiplePayoutsController controller; - final WalletsController walletsController; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final verificationController = - context.watch(); + final l10n = AppLocalizations.of(context)!; + final sourceController = context.watch(); + final verificationController = context + .watch(); final quotationProvider = context.watch(); - final verificationContextKey = quotationProvider.quotation?.quoteRef ?? + final verificationContextKey = + quotationProvider.quotation?.quoteRef ?? quotationProvider.quotation?.idempotencyKey; final isCooldownActive = verificationController.isCooldownActiveFor( verificationContextKey, ); final canSend = controller.canSend && !isCooldownActive; - return Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: theme.colorScheme.outlineVariant), + return SourceOfFundsPanel( + title: l10n.sourceOfFunds, + sourceSelector: SourceWalletSelector( + sourceController: sourceController, + isBusy: controller.isBusy, ), + visibleStates: const { + SourceOfFundsVisibleState.headerAction, + SourceOfFundsVisibleState.summary, + SourceOfFundsVisibleState.quoteStatus, + SourceOfFundsVisibleState.sendAction, + }, + stateWidgets: { + SourceOfFundsVisibleState.headerAction: _MultipleRefreshAction( + sourceController: sourceController, + ), + SourceOfFundsVisibleState.summary: SourceQuoteSummary( + controller: controller, + spacing: 12, + ), + SourceOfFundsVisibleState.quoteStatus: MultipleQuoteStatusCard( + controller: controller, + ), + SourceOfFundsVisibleState.sendAction: _MultipleSendAction( + controller: controller, + canSend: canSend, + isCooldownActive: isCooldownActive, + verificationController: verificationController, + verificationContextKey: verificationContextKey, + ), + }, + ); + } +} + +class _MultipleRefreshAction extends StatelessWidget { + const _MultipleRefreshAction({required this.sourceController}); + + final PaymentSourceController sourceController; + + @override + Widget build(BuildContext context) { + final selectedWallet = sourceController.selectedWallet; + if (selectedWallet == null) { + return const SizedBox.shrink(); + } + + return WalletBalanceRefreshButton(walletRef: selectedWallet.id); + } +} + +class _MultipleSendAction extends StatelessWidget { + const _MultipleSendAction({ + required this.controller, + required this.canSend, + required this.isCooldownActive, + required this.verificationController, + required this.verificationContextKey, + }); + + final MultiplePayoutsController controller; + final bool canSend; + final bool isCooldownActive; + final PayoutVerificationController verificationController; + final String? verificationContextKey; + + @override + Widget build(BuildContext context) { + return Center( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ - SourceQuotePanelHeader(), - const SizedBox(height: 8), - SourceWalletSelector( - walletsController: walletsController, - isBusy: controller.isBusy, + SendButton( + onPressed: () => handleMultiplePayoutSend(context, controller), + state: controller.isSending + ? ControlState.loading + : canSend + ? ControlState.enabled + : ControlState.disabled, ), - const SizedBox(height: 12), - const Divider(height: 1), - const SizedBox(height: 12), - SourceQuoteSummary(controller: controller, spacing: 12), - const SizedBox(height: 12), - MultipleQuoteStatusCard(controller: controller), - const SizedBox(height: 12), - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SendButton( - onPressed: () => handleMultiplePayoutSend(context, controller), - state: controller.isSending - ? ControlState.loading - : canSend - ? ControlState.enabled - : ControlState.disabled, - ), - if (isCooldownActive) ...[ - const SizedBox(height: 8), - CooldownHint( - seconds: verificationController.cooldownRemainingSecondsFor( - verificationContextKey, - ), - ), - ], - ], + if (isCooldownActive) ...[ + const SizedBox(height: 8), + CooldownHint( + seconds: verificationController.cooldownRemainingSecondsFor( + verificationContextKey, + ), ), - ), + ], ], ), ); } - } diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart index be4b1fab..7656329d 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/layout.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:pshared/controllers/balance_mask/wallets.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'; @@ -9,14 +7,9 @@ import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/panel_ class UploadCsvLayout extends StatelessWidget { - const UploadCsvLayout({ - super.key, - required this.controller, - required this.walletsController, - }); + const UploadCsvLayout({super.key, required this.controller}); final MultiplePayoutsController controller; - final WalletsController walletsController; @override Widget build(BuildContext context) { @@ -27,28 +20,17 @@ class UploadCsvLayout extends StatelessWidget { if (!useHorizontal) { return Column( children: [ - PanelCard( - child: UploadPanel( - controller: controller, - ), - ), + PanelCard(child: UploadPanel(controller: controller)), if (hasFile) ...[ const SizedBox(height: 12), - SourceQuotePanel( - controller: controller, - walletsController: walletsController, - ), + SourceQuotePanel(controller: controller), ], ], ); } if (!hasFile) { - return PanelCard( - child: UploadPanel( - controller: controller, - ), - ); + return PanelCard(child: UploadPanel(controller: controller)); } return IntrinsicHeight( @@ -57,19 +39,12 @@ class UploadCsvLayout extends StatelessWidget { children: [ Expanded( flex: 3, - child: PanelCard( - child: UploadPanel( - controller: controller, - ), - ), + child: PanelCard(child: UploadPanel(controller: controller)), ), const SizedBox(width: 12), Expanded( flex: 5, - child: SourceQuotePanel( - controller: controller, - walletsController: walletsController, - ), + child: SourceQuotePanel(controller: controller), ), ], ), diff --git a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart index 3be2a39f..31190c17 100644 --- a/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart +++ b/frontend/pweb/lib/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart @@ -6,7 +6,6 @@ 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'; - class UploadCSVSection extends StatelessWidget { const UploadCSVSection({super.key}); @@ -22,10 +21,7 @@ class UploadCSVSection extends StatelessWidget { children: [ UploadCsvHeader(theme: theme), const SizedBox(height: _verticalSpacing), - UploadCsvLayout( - controller: controller, - walletsController: context.watch(), - ), + UploadCsvLayout(controller: controller), ], ); } diff --git a/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart b/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart index 3cc76c8c..a938d566 100644 --- a/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart +++ b/frontend/pweb/lib/pages/payout_page/send/widgets/source_of_funds_card.dart @@ -4,13 +4,14 @@ import 'package:provider/provider.dart'; import 'package:pshared/controllers/payment/source.dart'; +import 'package:pweb/models/payment/source_funds.dart'; import 'package:pweb/pages/payout_page/send/widgets/method_selector.dart'; -import 'package:pweb/pages/payout_page/send/widgets/section/title.dart'; -import 'package:pweb/pages/payout_page/send/widgets/section/card.dart'; import 'package:pweb/utils/dimensions.dart'; +import 'package:pweb/widgets/payment/source_of_funds_panel.dart'; import 'package:pweb/widgets/refresh_balance/ledger.dart'; import 'package:pweb/widgets/refresh_balance/wallet.dart'; + class PaymentSourceOfFundsCard extends StatelessWidget { final AppDimensions dimensions; final String title; @@ -23,38 +24,33 @@ class PaymentSourceOfFundsCard extends StatelessWidget { @override Widget build(BuildContext context) { - return PaymentSectionCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded(child: SectionTitle(title)), - Consumer( - builder: (context, provider, _) { - final selectedWallet = provider.selectedWallet; - if (selectedWallet != null) { - return WalletBalanceRefreshButton( - walletRef: selectedWallet.id, - ); - } + return SourceOfFundsPanel( + title: title, + selectorSpacing: dimensions.paddingSmall, + sourceSelector: const PaymentMethodSelector(), + visibleStates: const { + SourceOfFundsVisibleState.headerAction, + }, + stateWidgets: { + SourceOfFundsVisibleState + .headerAction: Consumer( + builder: (context, provider, _) { + final selectedWallet = provider.selectedWallet; + if (selectedWallet != null) { + return WalletBalanceRefreshButton(walletRef: selectedWallet.id); + } - final selectedLedger = provider.selectedLedgerAccount; - if (selectedLedger != null) { - return LedgerBalanceRefreshButton( - ledgerAccountRef: selectedLedger.ledgerAccountRef, - ); - } + final selectedLedger = provider.selectedLedgerAccount; + if (selectedLedger != null) { + return LedgerBalanceRefreshButton( + ledgerAccountRef: selectedLedger.ledgerAccountRef, + ); + } - return const SizedBox.shrink(); - }, - ), - ], - ), - SizedBox(height: dimensions.paddingSmall), - const PaymentMethodSelector(), - ], - ), + return const SizedBox.shrink(); + }, + ), + }, ); } } diff --git a/frontend/pweb/lib/pages/report/cards/operation_card_utils.dart b/frontend/pweb/lib/pages/report/cards/operation_card_utils.dart index 1ddb222c..d81b9a7b 100644 --- a/frontend/pweb/lib/pages/report/cards/operation_card_utils.dart +++ b/frontend/pweb/lib/pages/report/cards/operation_card_utils.dart @@ -2,12 +2,9 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/payment/operation.dart'; - bool shouldShowToAmount(OperationItem operation) { if (operation.toCurrency.trim().isEmpty) return false; - if (operation.currency.trim().isEmpty) return true; - if (operation.currency != operation.toCurrency) return true; - return (operation.toAmount - operation.amount).abs() > 0.0001; + return true; } String formatOperationTime(BuildContext context, DateTime date) { diff --git a/frontend/pweb/lib/pages/report/details/content.dart b/frontend/pweb/lib/pages/report/details/content.dart index d40dd0a8..d882b0b6 100644 --- a/frontend/pweb/lib/pages/report/details/content.dart +++ b/frontend/pweb/lib/pages/report/details/content.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pweb/pages/report/details/header.dart'; @@ -13,12 +14,17 @@ class PaymentDetailsContent extends StatelessWidget { final Payment payment; final VoidCallback onBack; final VoidCallback? onDownloadAct; + final bool Function(PaymentExecutionOperation operation)? + canDownloadOperationDocument; + final ValueChanged? onDownloadOperationDocument; const PaymentDetailsContent({ super.key, required this.payment, required this.onBack, this.onDownloadAct, + this.canDownloadOperationDocument, + this.onDownloadOperationDocument, }); @override @@ -29,17 +35,15 @@ class PaymentDetailsContent extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - PaymentDetailsHeader( - title: loc.paymentInfo, - onBack: onBack, - ), + PaymentDetailsHeader(title: loc.paymentInfo, onBack: onBack), const SizedBox(height: 16), - PaymentSummaryCard( + PaymentSummaryCard(payment: payment, onDownloadAct: onDownloadAct), + const SizedBox(height: 16), + PaymentDetailsSections( payment: payment, - onDownloadAct: onDownloadAct, + canDownloadOperationDocument: canDownloadOperationDocument, + onDownloadOperationDocument: onDownloadOperationDocument, ), - const SizedBox(height: 16), - PaymentDetailsSections(payment: payment), ], ), ); diff --git a/frontend/pweb/lib/pages/report/details/page.dart b/frontend/pweb/lib/pages/report/details/page.dart index f7839b9d..5697c4c2 100644 --- a/frontend/pweb/lib/pages/report/details/page.dart +++ b/frontend/pweb/lib/pages/report/details/page.dart @@ -19,17 +19,17 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class PaymentDetailsPage extends StatelessWidget { final String paymentId; - const PaymentDetailsPage({ - super.key, - required this.paymentId, - }); + const PaymentDetailsPage({super.key, required this.paymentId}); @override Widget build(BuildContext context) { - return ChangeNotifierProxyProvider( + return ChangeNotifierProxyProvider< + PaymentsProvider, + PaymentDetailsController + >( create: (_) => PaymentDetailsController(paymentId: paymentId), - update: (_, payments, controller) => controller! - ..update(payments, paymentId), + update: (_, payments, controller) => + controller!..update(payments, paymentId), child: const _PaymentDetailsView(), ); } @@ -67,6 +67,17 @@ class _PaymentDetailsView extends StatelessWidget { onDownloadAct: controller.canDownload ? () => downloadPaymentAct(context, payment.paymentRef ?? '') : null, + canDownloadOperationDocument: + controller.canDownloadOperationDocument, + onDownloadOperationDocument: (operation) { + final request = controller.operationDocumentRequest(operation); + if (request == null) return; + downloadPaymentAct( + context, + request.paymentRef, + operationRef: request.operationRef, + ); + }, ); }, ), diff --git a/frontend/pweb/lib/pages/report/details/sections.dart b/frontend/pweb/lib/pages/report/details/sections.dart index a4419f86..79f69eca 100644 --- a/frontend/pweb/lib/pages/report/details/sections.dart +++ b/frontend/pweb/lib/pages/report/details/sections.dart @@ -1,36 +1,48 @@ import 'package:flutter/material.dart'; +import 'package:pshared/models/payment/execution_operation.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pweb/pages/report/details/sections/fx.dart'; -import 'package:pweb/pages/report/details/sections/metadata.dart'; +import 'package:pweb/pages/report/details/sections/operations/section.dart'; class PaymentDetailsSections extends StatelessWidget { final Payment payment; + final bool Function(PaymentExecutionOperation operation)? + canDownloadOperationDocument; + final ValueChanged? onDownloadOperationDocument; const PaymentDetailsSections({ super.key, required this.payment, + this.canDownloadOperationDocument, + this.onDownloadOperationDocument, }); @override Widget build(BuildContext context) { final hasFx = _hasFxQuote(payment); - if (!hasFx) { - return PaymentMetadataSection(payment: payment); - } + final hasOperations = payment.operations.isNotEmpty; - return Row( - crossAxisAlignment: CrossAxisAlignment.start, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded(child: PaymentFxSection(payment: payment)), - const SizedBox(width: 16), - Expanded(child: PaymentMetadataSection(payment: payment)), + if (hasFx) ...[ + PaymentFxSection(payment: payment), + const SizedBox(height: 16), + ], + if (hasOperations) ...[ + PaymentOperationsSection( + payment: payment, + canDownloadDocument: canDownloadOperationDocument, + onDownloadDocument: onDownloadOperationDocument, + ), + const SizedBox(height: 16), + ], ], ); } bool _hasFxQuote(Payment payment) => payment.lastQuote?.fxQuote != null; - } diff --git a/frontend/pweb/lib/pages/report/details/sections/operations/section.dart b/frontend/pweb/lib/pages/report/details/sections/operations/section.dart new file mode 100644 index 00000000..792907cf --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/sections/operations/section.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/execution_operation.dart'; +import 'package:pshared/models/payment/payment.dart'; + +import 'package:pweb/pages/report/details/section.dart'; +import 'package:pweb/pages/report/details/sections/operations/tile.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class PaymentOperationsSection extends StatelessWidget { + final Payment payment; + final bool Function(PaymentExecutionOperation operation)? canDownloadDocument; + final ValueChanged? onDownloadDocument; + + const PaymentOperationsSection({ + super.key, + required this.payment, + this.canDownloadDocument, + this.onDownloadDocument, + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final operations = payment.operations; + if (operations.isEmpty) { + return const SizedBox.shrink(); + } + + final children = []; + for (var i = 0; i < operations.length; i++) { + final operation = operations[i]; + final canDownload = canDownloadDocument?.call(operation) ?? false; + children.add( + OperationHistoryTile( + operation: operation, + canDownloadDocument: canDownload, + onDownloadDocument: canDownload && onDownloadDocument != null + ? () => onDownloadDocument!(operation) + : null, + ), + ); + if (i < operations.length - 1) { + children.addAll([ + const SizedBox(height: 8), + Divider( + height: 1, + color: Theme.of(context).dividerColor.withAlpha(20), + ), + const SizedBox(height: 8), + ]); + } + } + + return DetailsSection(title: loc.operationfryTitle, children: children); + } +} diff --git a/frontend/pweb/lib/pages/report/details/sections/operations/state_chip.dart b/frontend/pweb/lib/pages/report/details/sections/operations/state_chip.dart new file mode 100644 index 00000000..e859bc1e --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/sections/operations/state_chip.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/utils/payment/status_view.dart'; + + +class StepStateChip extends StatelessWidget { + final StatusView view; + + const StepStateChip({super.key, required this.view}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: view.backgroundColor, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + view.label.toUpperCase(), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: view.foregroundColor, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ); + } +} diff --git a/frontend/pweb/lib/pages/report/details/sections/operations/tile.dart b/frontend/pweb/lib/pages/report/details/sections/operations/tile.dart new file mode 100644 index 00000000..2afbf9f2 --- /dev/null +++ b/frontend/pweb/lib/pages/report/details/sections/operations/tile.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/payment/execution_operation.dart'; + +import 'package:pweb/utils/report/operations/state_mapper.dart'; +import 'package:pweb/pages/report/details/sections/operations/state_chip.dart'; +import 'package:pweb/utils/report/operations/time_format.dart'; +import 'package:pweb/utils/report/operations/title_mapper.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +class OperationHistoryTile extends StatelessWidget { + final PaymentExecutionOperation operation; + final bool canDownloadDocument; + final VoidCallback? onDownloadDocument; + + const OperationHistoryTile({ + super.key, + required this.operation, + required this.canDownloadDocument, + this.onDownloadDocument, + }); + + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final title = resolveOperationTitle(loc, operation.code); + final stateView = resolveStepStateView(context, operation.state); + final completedAt = formatCompletedAt(context, operation.completedAt); + final canDownload = canDownloadDocument && onDownloadDocument != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + title, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + StepStateChip(view: stateView), + ], + ), + const SizedBox(height: 6), + Text( + '${loc.completedAtLabel}: $completedAt', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (canDownload) ...[ + const SizedBox(height: 8), + TextButton.icon( + onPressed: onDownloadDocument, + icon: const Icon(Icons.download), + label: Text(loc.downloadAct), + ), + ], + ], + ); + } +} diff --git a/frontend/pweb/lib/pages/report/details/summary_card/widget.dart b/frontend/pweb/lib/pages/report/details/summary_card/widget.dart index 80041e68..ae3c19f8 100644 --- a/frontend/pweb/lib/pages/report/details/summary_card/widget.dart +++ b/frontend/pweb/lib/pages/report/details/summary_card/widget.dart @@ -42,7 +42,9 @@ class PaymentSummaryCard extends StatelessWidget { final feeLabel = formatMoney(fee); final paymentRef = (payment.paymentRef ?? '').trim(); - final showToAmount = toAmountLabel != '-' && toAmountLabel != amountLabel; + final showToAmount = toAmountLabel != '-'; + final showFee = payment.lastQuote != null; + final feeText = feeLabel != '-' ? loc.fee(feeLabel) : loc.fee(loc.noFee); final showPaymentId = paymentRef.isNotEmpty; final amountParts = splitAmount(amountLabel); @@ -85,10 +87,10 @@ class PaymentSummaryCard extends StatelessWidget { icon: Icons.south_east, text: loc.recipientWillReceive(toAmountLabel), ), - if (feeLabel != '-') + if (showFee) InfoLine( icon: Icons.receipt_long_outlined, - text: loc.fee(feeLabel), + text: feeText, muted: true, ), if (onDownloadAct != null) ...[ diff --git a/frontend/pweb/lib/pages/report/table/badge.dart b/frontend/pweb/lib/pages/report/table/badge.dart index 0ca4ea93..d42ae2d2 100644 --- a/frontend/pweb/lib/pages/report/table/badge.dart +++ b/frontend/pweb/lib/pages/report/table/badge.dart @@ -14,37 +14,34 @@ class OperationStatusBadge extends StatelessWidget { const OperationStatusBadge({super.key, required this.status}); - Color _badgeColor(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return operationStatusView(l10n, status).color; - } - - Color _textColor(Color background) { - // computeLuminance returns 0 for black, 1 for white - return background.computeLuminance() > 0.5 ? Colors.black : Colors.white; - } - @override Widget build(BuildContext context) { - final label = status.localized(context); - final bg = _badgeColor(context); - final fg = _textColor(bg); + final l10n = AppLocalizations.of(context)!; + final view = operationStatusView( + l10n, + Theme.of(context).colorScheme, + status, + ); + final label = view.label; + final bg = view.backgroundColor; + final fg = view.foregroundColor; return badges.Badge( badgeStyle: badges.BadgeStyle( shape: badges.BadgeShape.square, badgeColor: bg, - borderRadius: BorderRadius.circular(12), // fully rounded + borderRadius: BorderRadius.circular(12), // fully rounded padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2 // tighter padding + horizontal: 6, + vertical: 2, // tighter padding ), ), badgeContent: Text( - label.toUpperCase(), // or keep sentence case + label.toUpperCase(), // or keep sentence case style: TextStyle( color: fg, - fontSize: 11, // smaller text - fontWeight: FontWeight.w500, // medium weight + fontSize: 11, // smaller text + fontWeight: FontWeight.w500, // medium weight ), ), ); diff --git a/frontend/pweb/lib/pages/report/table/filters.dart b/frontend/pweb/lib/pages/report/table/filters.dart index 5390b284..c6d426d9 100644 --- a/frontend/pweb/lib/pages/report/table/filters.dart +++ b/frontend/pweb/lib/pages/report/table/filters.dart @@ -31,9 +31,7 @@ class OperationFilters extends StatelessWidget { : '${dateToLocalFormat(context, selectedRange!.start)} – ${dateToLocalFormat(context, selectedRange!.end)}'; return Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), elevation: 0, child: Padding( padding: const EdgeInsets.all(16), @@ -61,12 +59,12 @@ class OperationFilters extends StatelessWidget { OutlinedButton.icon( onPressed: onPickRange, icon: const Icon(Icons.date_range_outlined, size: 18), - label: Text( - periodLabel, - overflow: TextOverflow.ellipsis, - ), + label: Text(periodLabel, overflow: TextOverflow.ellipsis), style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), @@ -76,11 +74,7 @@ class OperationFilters extends StatelessWidget { Wrap( spacing: 10, runSpacing: 8, - children: const [ - OperationStatus.success, - OperationStatus.processing, - OperationStatus.error, - ].map((status) { + children: OperationStatus.values.map((status) { final label = status.localized(context); final isSelected = selectedStatuses.contains(status); return FilterChip( diff --git a/frontend/pweb/lib/utils/payment/operation_code.dart b/frontend/pweb/lib/utils/payment/operation_code.dart new file mode 100644 index 00000000..d3aa4aac --- /dev/null +++ b/frontend/pweb/lib/utils/payment/operation_code.dart @@ -0,0 +1,23 @@ +class OperationCodePair { + final String operation; + final String action; + + const OperationCodePair({required this.operation, required this.action}); +} + +OperationCodePair? parseOperationCodePair(String? code) { + final normalized = code?.trim().toLowerCase(); + if (normalized == null || normalized.isEmpty) return null; + + final parts = normalized.split('.').where((part) => part.isNotEmpty).toList(); + if (parts.length >= 4 && (parts.first == 'hop' || parts.first == 'edge')) { + return OperationCodePair(operation: parts[2], action: parts[3]); + } + if (parts.length >= 2) { + return OperationCodePair( + operation: parts[parts.length - 2], + action: parts.last, + ); + } + return null; +} diff --git a/frontend/pweb/lib/utils/payment/status_view.dart b/frontend/pweb/lib/utils/payment/status_view.dart index eb660e51..71f90e87 100644 --- a/frontend/pweb/lib/utils/payment/status_view.dart +++ b/frontend/pweb/lib/utils/payment/status_view.dart @@ -7,50 +7,150 @@ import 'package:pweb/generated/i18n/app_localizations.dart'; class StatusView { final String label; - final Color color; + final Color backgroundColor; + final Color foregroundColor; - const StatusView(this.label, this.color); -} + StatusView({ + required this.label, + required this.backgroundColor, + Color? foregroundColor, + }) : foregroundColor = + foregroundColor ?? + (backgroundColor.computeLuminance() > 0.5 + ? Colors.black + : Colors.white); -StatusView statusView(AppLocalizations l10n, String? raw) { - final trimmed = (raw ?? '').trim(); - final upper = trimmed.toUpperCase(); - final normalized = upper.startsWith('PAYMENT_STATE_') - ? upper.substring('PAYMENT_STATE_'.length) - : upper; - - switch (normalized) { - case 'SETTLED': - return StatusView(l10n.paymentStatusPending, Colors.orange); - case 'SUCCESS': - return StatusView(l10n.paymentStatusSuccessful, Colors.green); - case 'FUNDS_RESERVED': - return StatusView(l10n.paymentStatusReserved, Colors.blue); - case 'ACCEPTED': - return StatusView(l10n.paymentStatusProcessing, Colors.orange); - case 'SUBMITTED': - return StatusView(l10n.paymentStatusProcessing, Colors.blue); - case 'FAILED': - return StatusView(l10n.paymentStatusFailed, Colors.red); - case 'CANCELLED': - return StatusView(l10n.paymentStatusCancelled, Colors.grey); - case 'UNSPECIFIED': - case '': - default: - return StatusView(l10n.paymentStatusPending, Colors.grey); - } + Color get color => backgroundColor; } StatusView operationStatusView( AppLocalizations l10n, + ColorScheme scheme, OperationStatus status, ) { - switch (status) { - case OperationStatus.success: - return statusView(l10n, 'SUCCESS'); - case OperationStatus.error: - return statusView(l10n, 'FAILED'); - case OperationStatus.processing: - return statusView(l10n, 'ACCEPTED'); + return operationStatusViewFromToken( + l10n, + scheme, + operationStatusTokenFromEnum(status), + ); +} + +StatusView operationStatusViewFromToken( + AppLocalizations l10n, + ColorScheme scheme, + String? rawState, { + String? fallbackLabel, +}) { + final token = normalizeOperationStatusToken(rawState); + switch (token) { + case 'success': + case 'succeeded': + case 'completed': + case 'confirmed': + case 'settled': + return StatusView( + label: l10n.operationStatusSuccessful, + backgroundColor: scheme.tertiaryContainer, + foregroundColor: scheme.onTertiaryContainer, + ); + case 'skipped': + return StatusView( + label: l10n.operationStepStateSkipped, + backgroundColor: scheme.secondaryContainer, + foregroundColor: scheme.onSecondaryContainer, + ); + case 'error': + case 'failed': + case 'rejected': + case 'aborted': + return StatusView( + label: l10n.operationStatusUnsuccessful, + backgroundColor: scheme.errorContainer, + foregroundColor: scheme.onErrorContainer, + ); + case 'cancelled': + case 'canceled': + return StatusView( + label: l10n.paymentStatusCancelled, + backgroundColor: scheme.surfaceContainerHighest, + foregroundColor: scheme.onSurfaceVariant, + ); + case 'processing': + case 'running': + case 'executing': + case 'in_progress': + case 'started': + return StatusView( + label: l10n.paymentStatusProcessing, + backgroundColor: scheme.primaryContainer, + foregroundColor: scheme.onPrimaryContainer, + ); + case 'pending': + case 'queued': + case 'waiting': + case 'created': + case 'scheduled': + return StatusView( + label: l10n.operationStatusPending, + backgroundColor: scheme.secondary, + foregroundColor: scheme.onSecondary, + ); + case 'needs_attention': + return StatusView( + label: l10n.operationStepStateNeedsAttention, + backgroundColor: scheme.tertiary, + foregroundColor: scheme.onTertiary, + ); + case 'retrying': + return StatusView( + label: l10n.operationStepStateRetrying, + backgroundColor: scheme.primary, + foregroundColor: scheme.onPrimary, + ); + default: + return StatusView( + label: fallbackLabel ?? humanizeOperationStatusToken(token), + backgroundColor: scheme.surfaceContainerHighest, + foregroundColor: scheme.onSurfaceVariant, + ); } } + +String operationStatusTokenFromEnum(OperationStatus status) { + switch (status) { + case OperationStatus.pending: + return 'pending'; + case OperationStatus.processing: + return 'processing'; + case OperationStatus.retrying: + return 'retrying'; + case OperationStatus.success: + return 'success'; + case OperationStatus.skipped: + return 'skipped'; + case OperationStatus.cancelled: + return 'cancelled'; + case OperationStatus.needsAttention: + return 'needs_attention'; + case OperationStatus.error: + return 'error'; + } +} + +String normalizeOperationStatusToken(String? state) { + final normalized = (state ?? '').trim().toLowerCase(); + if (normalized.isEmpty) return 'pending'; + return normalized + .replaceFirst(RegExp(r'^step_execution_state_'), '') + .replaceFirst(RegExp(r'^orchestration_state_'), ''); +} + +String humanizeOperationStatusToken(String token) { + final parts = token.split('_').where((part) => part.isNotEmpty).toList(); + if (parts.isEmpty) return token; + return parts + .map( + (part) => '${part[0].toUpperCase()}${part.substring(1).toLowerCase()}', + ) + .join(' '); +} diff --git a/frontend/pweb/lib/utils/report/download_act.dart b/frontend/pweb/lib/utils/report/download_act.dart index 27930664..d0e79c97 100644 --- a/frontend/pweb/lib/utils/report/download_act.dart +++ b/frontend/pweb/lib/utils/report/download_act.dart @@ -11,7 +11,11 @@ import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; -Future downloadPaymentAct(BuildContext context, String paymentRef) async { +Future downloadPaymentAct( + BuildContext context, + String paymentRef, { + String? operationRef, +}) async { final organizations = context.read(); if (!organizations.isOrganizationSet) { return; @@ -28,6 +32,7 @@ Future downloadPaymentAct(BuildContext context, String paymentRef) async { final file = await PaymentDocumentsService.getAct( organizations.current.id, trimmed, + operationRef: operationRef, ); await downloadFile(file); }, diff --git a/frontend/pweb/lib/utils/report/operations.dart b/frontend/pweb/lib/utils/report/operations/operations.dart similarity index 100% rename from frontend/pweb/lib/utils/report/operations.dart rename to frontend/pweb/lib/utils/report/operations/operations.dart diff --git a/frontend/pweb/lib/utils/report/operations/state_mapper.dart b/frontend/pweb/lib/utils/report/operations/state_mapper.dart new file mode 100644 index 00000000..073f87fd --- /dev/null +++ b/frontend/pweb/lib/utils/report/operations/state_mapper.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/utils/payment/status_view.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +StatusView resolveStepStateView(BuildContext context, String? rawState) { + final loc = AppLocalizations.of(context)!; + final scheme = Theme.of(context).colorScheme; + return operationStatusViewFromToken(loc, scheme, rawState); +} diff --git a/frontend/pweb/lib/utils/report/operations/time_format.dart b/frontend/pweb/lib/utils/report/operations/time_format.dart new file mode 100644 index 00000000..95aabac1 --- /dev/null +++ b/frontend/pweb/lib/utils/report/operations/time_format.dart @@ -0,0 +1,15 @@ +import 'package:flutter/widgets.dart'; + +import 'package:pweb/utils/report/format.dart'; + + +String formatCompletedAt(BuildContext context, DateTime? completedAt) { + final value = meaningfulDate(completedAt); + return formatDateLabel(context, value); +} + +DateTime? meaningfulDate(DateTime? value) { + if (value == null) return null; + if (value.year <= 1) return null; + return value; +} diff --git a/frontend/pweb/lib/utils/report/operations/title_mapper.dart b/frontend/pweb/lib/utils/report/operations/title_mapper.dart new file mode 100644 index 00000000..21517199 --- /dev/null +++ b/frontend/pweb/lib/utils/report/operations/title_mapper.dart @@ -0,0 +1,59 @@ +import 'package:pweb/utils/payment/operation_code.dart'; + +import 'package:pweb/generated/i18n/app_localizations.dart'; + + +String resolveOperationTitle(AppLocalizations loc, String? code) { + final pair = parseOperationCodePair(code); + if (pair == null) return '-'; + + final operation = _localizedOperation(loc, pair.operation); + final action = _localizedAction(loc, pair.action); + return loc.paymentOperationPair(operation, action); +} + +String _localizedOperation(AppLocalizations loc, String operation) { + switch (operation) { + case 'card_payout': + return loc.paymentOperationCardPayout; + case 'crypto': + return loc.paymentOperationCrypto; + case 'settlement': + return loc.paymentOperationSettlement; + case 'ledger': + return loc.paymentOperationLedger; + default: + return _humanizeToken(operation); + } +} + +String _localizedAction(AppLocalizations loc, String action) { + switch (action) { + case 'send': + return loc.paymentOperationActionSend; + case 'observe': + return loc.paymentOperationActionObserve; + case 'fx_convert': + return loc.paymentOperationActionFxConvert; + case 'credit': + return loc.paymentOperationActionCredit; + case 'block': + return loc.paymentOperationActionBlock; + case 'debit': + return loc.paymentOperationActionDebit; + case 'release': + return loc.paymentOperationActionRelease; + case 'move': + return loc.paymentOperationActionMove; + default: + return _humanizeToken(action); + } +} + +String _humanizeToken(String token) { + final parts = token.split('_').where((part) => part.isNotEmpty).toList(); + if (parts.isEmpty) return token; + return parts + .map((part) => '${part[0].toUpperCase()}${part.substring(1)}') + .join(' '); +} diff --git a/frontend/pweb/lib/utils/report/payment_mapper.dart b/frontend/pweb/lib/utils/report/payment_mapper.dart index 0aef8575..f76b2ad3 100644 --- a/frontend/pweb/lib/utils/report/payment_mapper.dart +++ b/frontend/pweb/lib/utils/report/payment_mapper.dart @@ -56,11 +56,14 @@ OperationStatus statusFromPayment(Payment payment) { return OperationStatus.error; case PaymentOrchestrationState.settled: return OperationStatus.success; - case PaymentOrchestrationState.created: - case PaymentOrchestrationState.executing: case PaymentOrchestrationState.needsAttention: - case PaymentOrchestrationState.unspecified: + return OperationStatus.needsAttention; + case PaymentOrchestrationState.created: + return OperationStatus.pending; + case PaymentOrchestrationState.executing: return OperationStatus.processing; + case PaymentOrchestrationState.unspecified: + return OperationStatus.pending; } } diff --git a/frontend/pweb/lib/widgets/payment/source_of_funds_panel.dart b/frontend/pweb/lib/widgets/payment/source_of_funds_panel.dart new file mode 100644 index 00000000..84aeeb79 --- /dev/null +++ b/frontend/pweb/lib/widgets/payment/source_of_funds_panel.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import 'package:pweb/models/payment/source_funds.dart'; +import 'package:pweb/pages/payout_page/send/widgets/section/card.dart'; +import 'package:pweb/pages/payout_page/send/widgets/section/title.dart'; + + +class SourceOfFundsPanel extends StatelessWidget { + const SourceOfFundsPanel({ + super.key, + required this.title, + required this.sourceSelector, + this.visibleStates = const {}, + this.stateWidgets = const {}, + this.selectorSpacing = 8, + this.sectionSpacing = 12, + this.padding, + }); + + final String title; + final Widget sourceSelector; + final Set visibleStates; + final Map stateWidgets; + final double selectorSpacing; + final double sectionSpacing; + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + final headerAction = _stateWidget(SourceOfFundsVisibleState.headerAction); + final headerActions = headerAction == null + ? const [] + : [headerAction]; + final bodySections = _buildBodySections(); + + return PaymentSectionCard( + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: SectionTitle(title)), + ...headerActions, + ], + ), + SizedBox(height: selectorSpacing), + sourceSelector, + if (bodySections.isNotEmpty) ...[ + SizedBox(height: sectionSpacing), + const Divider(height: 1), + SizedBox(height: sectionSpacing), + ...bodySections, + ], + ], + ), + ); + } + + List _buildBodySections() { + const orderedStates = [ + SourceOfFundsVisibleState.summary, + SourceOfFundsVisibleState.quoteStatus, + SourceOfFundsVisibleState.sendAction, + ]; + + final sections = []; + for (final state in orderedStates) { + final section = _stateWidget(state); + if (section == null) continue; + if (sections.isNotEmpty) { + sections.add(SizedBox(height: sectionSpacing)); + } + sections.add(section); + } + return sections; + } + + Widget? _stateWidget(SourceOfFundsVisibleState state) { + if (!visibleStates.contains(state)) return null; + return stateWidgets[state]; + } +} diff --git a/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart b/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart index d7ca0714..fc8dd7e8 100644 --- a/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart +++ b/frontend/pweb/lib/widgets/payment/source_wallet_selector.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:pshared/controllers/balance_mask/wallets.dart'; import 'package:pshared/controllers/payment/source.dart'; import 'package:pshared/models/ledger/account.dart'; import 'package:pshared/models/payment/source_type.dart'; @@ -10,78 +9,52 @@ import 'package:pshared/utils/money.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; - typedef _SourceOptionKey = ({PaymentSourceType type, String ref}); class SourceWalletSelector extends StatelessWidget { const SourceWalletSelector({ super.key, - this.walletsController, - this.sourceController, + required this.sourceController, this.isBusy = false, this.onChanged, - }) : assert( - (walletsController != null) != (sourceController != null), - 'Provide either walletsController or sourceController', - ); + }); - final WalletsController? walletsController; - final PaymentSourceController? sourceController; + final PaymentSourceController sourceController; final bool isBusy; final ValueChanged? onChanged; @override Widget build(BuildContext context) { final source = sourceController; - if (source != null) { - final selectedWallet = source.selectedWallet; - final selectedLedger = source.selectedLedgerAccount; - final selectedValue = switch (source.selectedType) { - PaymentSourceType.wallet => - selectedWallet == null ? null : _walletKey(selectedWallet.id), - PaymentSourceType.ledger => - selectedLedger == null - ? null - : _ledgerKey(selectedLedger.ledgerAccountRef), - null => null, - }; + final selectedWallet = source.selectedWallet; + final selectedLedger = source.selectedLedgerAccount; + final selectedValue = switch (source.selectedType) { + PaymentSourceType.wallet => + selectedWallet == null ? null : _walletKey(selectedWallet.id), + PaymentSourceType.ledger => + selectedLedger == null + ? null + : _ledgerKey(selectedLedger.ledgerAccountRef), + null => null, + }; - return _buildSourceSelector( - context: context, - wallets: source.wallets, - ledgerAccounts: source.ledgerAccounts, - selectedValue: selectedValue, - onChanged: (value) { - if (value.type == PaymentSourceType.wallet) { - source.selectWalletByRef(value.ref); - final selected = source.selectedWallet; - if (selected != null) { - onChanged?.call(selected); - } - return; - } - - if (value.type == PaymentSourceType.ledger) { - source.selectLedgerByRef(value.ref); - } - }, - ); - } - - final wallets = walletsController!; return _buildSourceSelector( context: context, - wallets: wallets.wallets, - ledgerAccounts: const [], - selectedValue: wallets.selectedWalletRef == null - ? null - : _walletKey(wallets.selectedWalletRef!), + wallets: source.wallets, + ledgerAccounts: source.ledgerAccounts, + selectedValue: selectedValue, onChanged: (value) { - if (value.type != PaymentSourceType.wallet) return; - wallets.selectWalletByRef(value.ref); - final selected = wallets.selectedWallet; - if (selected != null) { - onChanged?.call(selected); + if (value.type == PaymentSourceType.wallet) { + source.selectWalletByRef(value.ref); + final selected = source.selectedWallet; + if (selected != null) { + onChanged?.call(selected); + } + return; + } + + if (value.type == PaymentSourceType.ledger) { + source.selectLedgerByRef(value.ref); } }, ); @@ -106,7 +79,7 @@ class SourceWalletSelector extends StatelessWidget { return DropdownMenuItem<_SourceOptionKey>( value: _walletKey(wallet.id), child: Text( - '${wallet.name} - ${_walletBalance(wallet)}', + '${_walletDisplayName(wallet, l10n)} - ${_walletBalance(wallet)}', overflow: TextOverflow.ellipsis, ), ); @@ -115,7 +88,7 @@ class SourceWalletSelector extends StatelessWidget { return DropdownMenuItem<_SourceOptionKey>( value: _ledgerKey(ledger.ledgerAccountRef), child: Text( - '${ledger.name} - ${_ledgerBalance(ledger)}', + '${_ledgerDisplayName(ledger, l10n)} - ${_ledgerBalance(ledger)}', overflow: TextOverflow.ellipsis, ), ); @@ -157,6 +130,20 @@ class SourceWalletSelector extends StatelessWidget { _SourceOptionKey _ledgerKey(String ledgerAccountRef) => (type: PaymentSourceType.ledger, ref: ledgerAccountRef); + String _walletDisplayName(Wallet wallet, AppLocalizations l10n) { + final name = wallet.name.trim(); + if (name.isNotEmpty) return name; + + return l10n.paymentTypeWallet; + } + + String _ledgerDisplayName(LedgerAccount ledger, AppLocalizations l10n) { + final name = ledger.name.trim(); + if (name.isNotEmpty) return name; + + return l10n.paymentTypeLedger; + } + String _walletBalance(Wallet wallet) { final symbol = currencyCodeToSymbol(wallet.currency); return '$symbol ${amountToString(wallet.balance)}';