added download for operation and included fixes for source of payments #639

Merged
tech merged 3 commits from SEND063 into main 2026-03-05 08:29:46 +00:00
59 changed files with 1389 additions and 531 deletions

View File

@@ -0,0 +1,34 @@
import 'package:json_annotation/json_annotation.dart';
part 'operation.g.dart';
@JsonSerializable()
class PaymentOperationDTO {
final String? stepRef;
final String? operationRef;
final String? gateway;
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.gateway,
this.code,
this.state,
this.label,
this.failureCode,
this.failureReason,
Review

Давай на JSON serialisable перейдем

Давай на JSON serialisable перейдем
this.startedAt,
this.completedAt,
});
factory PaymentOperationDTO.fromJson(Map<String, dynamic> json) =>
_$PaymentOperationDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentOperationDTOToJson(this);
}

View File

@@ -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,7 @@ class PaymentDTO {
final String? state;
final String? failureCode;
final String? failureReason;
final List<PaymentOperationDTO> operations;
final PaymentQuoteDTO? lastQuote;
final Map<String, String>? metadata;
final String? createdAt;
@@ -22,6 +24,7 @@ class PaymentDTO {
this.state,
this.failureCode,
this.failureReason,
this.operations = const <PaymentOperationDTO>[],
this.lastQuote,
this.metadata,
this.createdAt,

View File

@@ -9,7 +9,14 @@ import 'package:pshared/models/ledger/account.dart';
extension LedgerAccountDTOMapper on LedgerAccountDTO {
LedgerAccount toDomain() => LedgerAccount(
LedgerAccount toDomain() {
final mappedDescribable = describable?.toDomain();
final fallbackName = metadata?['name']?.trim() ?? '';
final name = mappedDescribable?.name.trim().isNotEmpty == true
? mappedDescribable!.name
: fallbackName;
return LedgerAccount(
ledgerAccountRef: ledgerAccountRef,
organizationRef: organizationRef,
ownerRef: ownerRef,
@@ -22,10 +29,14 @@ extension LedgerAccountDTOMapper on LedgerAccountDTO {
metadata: metadata,
createdAt: createdAt,
updatedAt: updatedAt,
describable: describable?.toDomain() ?? newDescribable(name: '', description: null),
describable: newDescribable(
name: name,
description: mappedDescribable?.description,
),
balance: balance?.toDomain(),
);
}
}
extension LedgerAccountModelMapper on LedgerAccount {
LedgerAccountDTO toDTO() => LedgerAccountDTO(

View File

@@ -0,0 +1,39 @@
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,
gateway: gateway,
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,
gateway: gateway,
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);
}

View File

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

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -0,0 +1,25 @@
class PaymentExecutionOperation {
final String? stepRef;
final String? operationRef;
final String? gateway;
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.gateway,
required this.code,
required this.state,
required this.label,
required this.failureCode,
required this.failureReason,
required this.startedAt,
required this.completedAt,
});
}

View File

@@ -1,7 +1,6 @@
import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/status.dart';
class OperationItem {
final OperationStatus status;
final String? fileName;
@@ -11,6 +10,8 @@ class OperationItem {
final String toCurrency;
final String payId;
final String? paymentRef;
final String? operationRef;
final String? gatewayService;
final String? cardNumber;
final PaymentMethod? paymentMethod;
final String name;
@@ -26,6 +27,8 @@ class OperationItem {
required this.toCurrency,
required this.payId,
this.paymentRef,
this.operationRef,
this.gatewayService,
this.cardNumber,
this.paymentMethod,
required this.name,

View File

@@ -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<PaymentExecutionOperation> operations;
final PaymentQuote? lastQuote;
final Map<String, String>? 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,

View File

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

View File

@@ -9,13 +9,27 @@ class PaymentDocumentsService {
static final _logger = Logger('service.payment_documents');
static const String _objectType = Services.payments;
static Future<DownloadedFile> getAct(String organizationRef, String paymentRef) async {
final encodedRef = Uri.encodeQueryComponent(paymentRef);
final url = '/documents/act/$organizationRef?payment_ref=$encodedRef';
_logger.fine('Downloading act document for payment $paymentRef');
final response = await AuthorizationService.getGETBinaryResponse(_objectType, url);
final filename = _filenameFromDisposition(response.header('content-disposition')) ??
'act_$paymentRef.pdf';
static Future<DownloadedFile> getOperationDocument(
String organizationRef,
String gatewayService,
String operationRef,
) async {
final query = <String, String>{
'gateway_service': gatewayService,
'operation_ref': operationRef,
};
final queryString = Uri(queryParameters: query).query;
final url = '/documents/operation/$organizationRef?$queryString';
_logger.fine(
'Downloading operation document for operation $operationRef in gateway $gatewayService',
);
final response = await AuthorizationService.getGETBinaryResponse(
_objectType,
url,
);
final filename =
_filenameFromDisposition(response.header('content-disposition')) ??
'operation_$operationRef.pdf';
final mimeType = response.header('content-type') ?? 'application/pdf';
return DownloadedFile(
bytes: response.bytes,

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
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/report/operations/document_rule.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
class PaymentDetailsController extends ChangeNotifier {
PaymentDetailsController({required String paymentId})
: _paymentId = paymentId;
@@ -23,12 +25,44 @@ class PaymentDetailsController extends ChangeNotifier {
bool get canDownload {
final current = _payment;
if (current == null) return false;
final status = statusFromPayment(current);
final paymentRef = current.paymentRef ?? '';
return status == OperationStatus.success &&
paymentRef.trim().isNotEmpty;
if (statusFromPayment(current) != OperationStatus.success) return false;
return primaryOperationDocumentRequest != null;
}
OperationDocumentRequestModel? get primaryOperationDocumentRequest {
final current = _payment;
if (current == null) return null;
for (final operation in current.operations) {
final request = operationDocumentRequest(operation);
if (request != null) {
return request;
}
}
return null;
}
OperationDocumentRequestModel? operationDocumentRequest(
PaymentExecutionOperation operation,
) {
final current = _payment;
if (current == null) return null;
final operationRef = operation.operationRef;
if (operationRef == null || operationRef.isEmpty) return null;
final gatewayService = operation.gateway;
if (gatewayService == null || gatewayService.isEmpty) return null;
if (!isOperationDocumentEligible(operation.code)) return null;
return OperationDocumentRequestModel(
gatewayService: gatewayService,
operationRef: operationRef,
);
}
bool canDownloadOperationDocument(PaymentExecutionOperation operation) =>
operationDocumentRequest(operation) != null;
void update(PaymentsProvider provider, String paymentId) {
if (_paymentId != paymentId) {
_paymentId = paymentId;

View File

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

View File

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

View File

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

View File

@@ -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": "Получателю поступит",

View File

@@ -0,0 +1,9 @@
class OperationDocumentRequestModel {
final String gatewayService;
final String operationRef;
const OperationDocumentRequestModel({
required this.gatewayService,
required this.operationRef,
});
}

View File

@@ -0,0 +1,6 @@
enum SourceOfFundsVisibleState {
headerAction,
summary,
quoteStatus,
sendAction,
}

View File

@@ -0,0 +1,9 @@
class OperationDocumentInfo {
final String operationRef;
final String gatewayService;
const OperationDocumentInfo({
required this.operationRef,
required this.gatewayService,
});
}

View File

@@ -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<WalletsController>().toggleBalanceMask(wallet.id);
},
),
Column(
children: [
WalletBalanceRefreshButton(
walletRef: wallet.id,
),
BalanceAddFunds(onTopUp: onTopUp),
],
),
],
),
BalanceAddFunds(onTopUp: onTopUp),
],
),
),

View File

@@ -17,10 +17,7 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class LedgerAccountCard extends StatelessWidget {
final LedgerAccount account;
const LedgerAccountCard({
super.key,
required this.account,
});
const LedgerAccountCard({super.key, required this.account});
String _formatBalance() {
final money = account.balance?.balance;
@@ -62,8 +59,13 @@ class LedgerAccountCard extends StatelessWidget {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final loc = AppLocalizations.of(context)!;
final subtitle = account.name.isNotEmpty ? account.name : account.accountCode;
final badge = account.currency.trim().isEmpty ? null : account.currency.toUpperCase();
final accountName = account.name.trim();
final accountCode = account.accountCode.trim();
final title = accountName.isNotEmpty ? accountName : loc.paymentTypeLedger;
final subtitle = accountCode.isNotEmpty ? accountCode : null;
final badge = account.currency.trim().isEmpty
? null
: account.currency.toUpperCase();
return Card(
color: colorScheme.onSecondary,
@@ -76,16 +78,14 @@ class LedgerAccountCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BalanceHeader(
title: loc.paymentTypeLedger,
subtitle: subtitle.isNotEmpty ? subtitle : null,
badge: badge,
),
BalanceHeader(title: title, subtitle: subtitle, badge: badge),
Row(
children: [
Consumer<LedgerBalanceMaskController>(
builder: (context, controller, _) {
final isMasked = controller.isBalanceMasked(account.ledgerAccountRef);
final isMasked = controller.isBalanceMasked(
account.ledgerAccountRef,
);
return Row(
children: [
Text(
@@ -97,7 +97,9 @@ class LedgerAccountCard extends StatelessWidget {
),
const SizedBox(width: 12),
GestureDetector(
onTap: () => controller.toggleBalanceMask(account.ledgerAccountRef),
onTap: () => controller.toggleBalanceMask(
account.ledgerAccountRef,
),
child: Icon(
isMasked ? Icons.visibility_off : Icons.visibility,
size: 24,

View File

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

View File

@@ -2,68 +2,112 @@ 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_wallet_selector.dart';
import 'package:pweb/widgets/payment/source_of_funds_panel.dart';
import 'package:pweb/widgets/payment/source_wallet_selector/view.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<PayoutVerificationController>();
final l10n = AppLocalizations.of(context)!;
final sourceController = context.watch<PaymentSourceController>();
final verificationController = context
.watch<PayoutVerificationController>();
final quotationProvider = context.watch<MultiQuotationProvider>();
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),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SourceQuotePanelHeader(),
const SizedBox(height: 8),
SourceWalletSelector(
walletsController: walletsController,
return SourceOfFundsPanel(
title: l10n.sourceOfFunds,
sourceSelector: SourceWalletSelector(
sourceController: sourceController,
isBusy: controller.isBusy,
),
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(
visibleStates: const <SourceOfFundsVisibleState>{
SourceOfFundsVisibleState.headerAction,
SourceOfFundsVisibleState.summary,
SourceOfFundsVisibleState.quoteStatus,
SourceOfFundsVisibleState.sendAction,
},
stateWidgets: <SourceOfFundsVisibleState, Widget>{
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(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -85,10 +129,6 @@ class SourceQuotePanel extends StatelessWidget {
],
],
),
),
],
),
);
}
}

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
import 'package:pshared/controllers/payment/source.dart';
import 'package:pweb/widgets/payment/source_wallet_selector.dart';
import 'package:pweb/widgets/payment/source_wallet_selector/view.dart';
class PaymentMethodSelector extends StatelessWidget {
const PaymentMethodSelector({super.key});

View File

@@ -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,20 +24,20 @@ class PaymentSourceOfFundsCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PaymentSectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: SectionTitle(title)),
Consumer<PaymentSourceController>(
return SourceOfFundsPanel(
title: title,
selectorSpacing: dimensions.paddingSmall,
sourceSelector: const PaymentMethodSelector(),
visibleStates: const <SourceOfFundsVisibleState>{
SourceOfFundsVisibleState.headerAction,
},
stateWidgets: <SourceOfFundsVisibleState, Widget>{
SourceOfFundsVisibleState
.headerAction: Consumer<PaymentSourceController>(
builder: (context, provider, _) {
final selectedWallet = provider.selectedWallet;
if (selectedWallet != null) {
return WalletBalanceRefreshButton(
walletRef: selectedWallet.id,
);
return WalletBalanceRefreshButton(walletRef: selectedWallet.id);
}
final selectedLedger = provider.selectedLedgerAccount;
@@ -49,12 +50,7 @@ class PaymentSourceOfFundsCard extends StatelessWidget {
return const SizedBox.shrink();
},
),
],
),
SizedBox(height: dimensions.paddingSmall),
const PaymentMethodSelector(),
],
),
},
);
}
}

View File

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

View File

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

View File

@@ -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<PaymentsProvider, PaymentDetailsController>(
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(),
);
}
@@ -65,8 +65,27 @@ class _PaymentDetailsView extends StatelessWidget {
payment: payment,
onBack: () => _handleBack(context),
onDownloadAct: controller.canDownload
? () => downloadPaymentAct(context, payment.paymentRef ?? '')
? () {
final request = controller.primaryOperationDocumentRequest;
if (request == null) return;
downloadPaymentAct(
context,
gatewayService: request.gatewayService,
operationRef: request.operationRef,
);
}
: null,
canDownloadOperationDocument:
controller.canDownloadOperationDocument,
onDownloadOperationDocument: (operation) {
final request = controller.operationDocumentRequest(operation);
if (request == null) return;
downloadPaymentAct(
context,
gatewayService: request.gatewayService,
operationRef: request.operationRef,
);
},
);
},
),

View File

@@ -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<PaymentExecutionOperation>? 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;
}

View File

@@ -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<PaymentExecutionOperation>? 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 = <Widget>[];
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);
}
}

View File

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

View File

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

View File

@@ -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) ...[

View File

@@ -14,21 +14,17 @@ 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(
@@ -36,7 +32,8 @@ class OperationStatusBadge extends StatelessWidget {
badgeColor: bg,
borderRadius: BorderRadius.circular(12), // fully rounded
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2 // tighter padding
horizontal: 6,
vertical: 2, // tighter padding
),
),
badgeContent: Text(

View File

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

View File

@@ -9,7 +9,6 @@ import 'package:pweb/utils/report/download_act.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationRow {
static DataRow build(OperationItem op, BuildContext context) {
final isUnknownDate = op.date.millisecondsSinceEpoch == 0;
@@ -20,18 +19,25 @@ class OperationRow {
: '${TimeOfDay.fromDateTime(localDate).format(context)}\n'
'${localDate.toIso8601String().split("T").first}';
final canDownload = op.status == OperationStatus.success &&
(op.paymentRef ?? '').trim().isNotEmpty;
final canDownload =
op.status == OperationStatus.success &&
(op.operationRef ?? '').trim().isNotEmpty &&
(op.gatewayService ?? '').trim().isNotEmpty;
final documentCell = canDownload
? TextButton.icon(
onPressed: () => downloadPaymentAct(context, op.paymentRef ?? ''),
onPressed: () => downloadPaymentAct(
context,
gatewayService: op.gatewayService ?? '',
operationRef: op.operationRef ?? '',
),
icon: const Icon(Icons.download),
label: Text(loc.downloadAct),
)
: Text(op.fileName ?? '');
return DataRow(cells: [
return DataRow(
cells: [
DataCell(OperationStatusBadge(status: op.status)),
DataCell(documentCell),
DataCell(Text('${amountToString(op.amount)} ${op.currency}')),
@@ -41,6 +47,7 @@ class OperationRow {
DataCell(Text(op.name)),
DataCell(Text(dateLabel)),
DataCell(Text(op.comment)),
]);
],
);
}
}

View File

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

View File

@@ -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,
) {
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.success:
return statusView(l10n, 'SUCCESS');
case OperationStatus.error:
return statusView(l10n, 'FAILED');
case OperationStatus.pending:
return 'pending';
case OperationStatus.processing:
return statusView(l10n, 'ACCEPTED');
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(' ');
}

View File

@@ -10,14 +10,18 @@ import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
Future<void> downloadPaymentAct(BuildContext context, String paymentRef) async {
Future<void> downloadPaymentAct(
BuildContext context, {
required String gatewayService,
required String operationRef,
}) async {
final organizations = context.read<OrganizationsProvider>();
if (!organizations.isOrganizationSet) {
return;
}
final trimmed = paymentRef.trim();
if (trimmed.isEmpty) {
final gateway = gatewayService.trim();
final operation = operationRef.trim();
if (gateway.isEmpty || operation.isEmpty) {
return;
}
@@ -25,9 +29,10 @@ Future<void> downloadPaymentAct(BuildContext context, String paymentRef) async {
await executeActionWithNotification(
context: context,
action: () async {
final file = await PaymentDocumentsService.getAct(
final file = await PaymentDocumentsService.getOperationDocument(
organizations.current.id,
trimmed,
gateway,
operation,
);
await downloadFile(file);
},

View File

@@ -0,0 +1,14 @@
import 'package:pweb/utils/payment/operation_code.dart';
const String _documentOperation = 'card_payout';
const String _documentAction = 'send';
bool isOperationDocumentEligible(String? operationCode) {
final pair = parseOperationCodePair(operationCode);
if (pair == null) return false;
return _isDocumentOperationPair(pair);
}
bool _isDocumentOperationPair(OperationCodePair pair) {
return pair.operation == _documentOperation && pair.action == _documentAction;
}

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ import 'package:pshared/models/payment/state.dart';
import 'package:pshared/models/payment/status.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/models/report/operation/document.dart';
import 'package:pweb/utils/report/operations/document_rule.dart';
OperationItem mapPaymentToOperation(Payment payment) {
final debit = payment.lastQuote?.amounts?.sourceDebitTotal;
@@ -33,6 +35,7 @@ OperationItem mapPaymentToOperation(Payment payment) {
payment.state,
]) ??
'';
final operationDocument = _resolveOperationDocument(payment);
return OperationItem(
status: statusFromPayment(payment),
@@ -43,6 +46,8 @@ OperationItem mapPaymentToOperation(Payment payment) {
toCurrency: toCurrency,
payId: payId,
paymentRef: payment.paymentRef,
operationRef: operationDocument?.operationRef,
gatewayService: operationDocument?.gatewayService,
cardNumber: null,
name: name,
date: resolvePaymentDate(payment),
@@ -50,17 +55,37 @@ OperationItem mapPaymentToOperation(Payment payment) {
);
}
OperationDocumentInfo? _resolveOperationDocument(Payment payment) {
for (final operation in payment.operations) {
final operationRef = operation.operationRef;
final gatewayService = operation.gateway;
if (operationRef == null || operationRef.isEmpty) continue;
if (gatewayService == null || gatewayService.isEmpty) continue;
if (!isOperationDocumentEligible(operation.code)) continue;
return OperationDocumentInfo(
operationRef: operationRef,
Review

вот, говорил: вот эту штуку хардкодить не надо, лучше вынести куда-то в решающее правило, а не запаковывать внутрь провайдера, где найти ее непросто.

вот, говорил: вот эту штуку хардкодить не надо, лучше вынести куда-то в решающее правило, а не запаковывать внутрь провайдера, где найти ее непросто.
gatewayService: gatewayService,
);
}
return null;
}
OperationStatus statusFromPayment(Payment payment) {
switch (payment.orchestrationState) {
case PaymentOrchestrationState.failed:
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;
}
}

View File

@@ -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 <SourceOfFundsVisibleState>{},
this.stateWidgets = const <SourceOfFundsVisibleState, Widget>{},
this.selectorSpacing = 8,
this.sectionSpacing = 12,
this.padding,
});
final String title;
final Widget sourceSelector;
final Set<SourceOfFundsVisibleState> visibleStates;
final Map<SourceOfFundsVisibleState, Widget> 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 <Widget>[]
: <Widget>[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<Widget> _buildBodySections() {
const orderedStates = <SourceOfFundsVisibleState>[
SourceOfFundsVisibleState.summary,
SourceOfFundsVisibleState.quoteStatus,
SourceOfFundsVisibleState.sendAction,
];
final sections = <Widget>[];
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];
}
}

View File

@@ -1,185 +0,0 @@
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';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/utils/currency.dart';
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,
this.isBusy = false,
this.onChanged,
}) : assert(
(walletsController != null) != (sourceController != null),
'Provide either walletsController or sourceController',
);
final WalletsController? walletsController;
final PaymentSourceController? sourceController;
final bool isBusy;
final ValueChanged<Wallet>? 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,
};
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 <LedgerAccount>[],
selectedValue: wallets.selectedWalletRef == null
? null
: _walletKey(wallets.selectedWalletRef!),
onChanged: (value) {
if (value.type != PaymentSourceType.wallet) return;
wallets.selectWalletByRef(value.ref);
final selected = wallets.selectedWallet;
if (selected != null) {
onChanged?.call(selected);
}
},
);
}
Widget _buildSourceSelector({
required BuildContext context,
required List<Wallet> wallets,
required List<LedgerAccount> ledgerAccounts,
required _SourceOptionKey? selectedValue,
required ValueChanged<_SourceOptionKey> onChanged,
}) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
if (wallets.isEmpty && ledgerAccounts.isEmpty) {
return Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall);
}
final items = <DropdownMenuItem<_SourceOptionKey>>[
...wallets.map((wallet) {
return DropdownMenuItem<_SourceOptionKey>(
value: _walletKey(wallet.id),
child: Text(
'${wallet.name} - ${_walletBalance(wallet)}',
overflow: TextOverflow.ellipsis,
),
);
}),
...ledgerAccounts.map((ledger) {
return DropdownMenuItem<_SourceOptionKey>(
value: _ledgerKey(ledger.ledgerAccountRef),
child: Text(
'${ledger.name} - ${_ledgerBalance(ledger)}',
overflow: TextOverflow.ellipsis,
),
);
}),
];
final knownValues = items
.map((item) => item.value)
.whereType<_SourceOptionKey>()
.toSet();
final effectiveValue = knownValues.contains(selectedValue)
? selectedValue
: null;
return DropdownButtonFormField<_SourceOptionKey>(
initialValue: effectiveValue,
isExpanded: true,
decoration: InputDecoration(
labelText: l10n.whereGetMoney,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
items: items,
onChanged: isBusy
? null
: (value) {
if (value == null) return;
onChanged(value);
},
);
}
_SourceOptionKey _walletKey(String walletRef) =>
(type: PaymentSourceType.wallet, ref: walletRef);
_SourceOptionKey _ledgerKey(String ledgerAccountRef) =>
(type: PaymentSourceType.ledger, ref: ledgerAccountRef);
String _walletBalance(Wallet wallet) {
final symbol = currencyCodeToSymbol(wallet.currency);
return '$symbol ${amountToString(wallet.balance)}';
}
String _ledgerBalance(LedgerAccount account) {
final money = account.balance?.balance;
final rawAmount = money?.amount.trim();
final amount = parseMoneyAmount(rawAmount, fallback: double.nan);
final amountText = amount.isNaN
? (rawAmount == null || rawAmount.isEmpty ? '--' : rawAmount)
: amountToString(amount);
final currencyCode = (money?.currency ?? account.currency)
.trim()
.toUpperCase();
final symbol = currencySymbolFromCode(currencyCode);
if (symbol != null && symbol.trim().isNotEmpty) {
return '$symbol $amountText';
}
if (currencyCode.isNotEmpty) {
return '$amountText $currencyCode';
}
return amountText;
}
}

View File

@@ -0,0 +1,31 @@
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
String walletBalance(Wallet wallet) {
final symbol = currencyCodeToSymbol(wallet.currency);
return '$symbol ${amountToString(wallet.balance)}';
}
String ledgerBalance(LedgerAccount account) {
final money = account.balance?.balance;
final rawAmount = money?.amount.trim();
final amount = parseMoneyAmount(rawAmount, fallback: double.nan);
final amountText = amount.isNaN
? (rawAmount == null || rawAmount.isEmpty ? '--' : rawAmount)
: amountToString(amount);
final currencyCode = (money?.currency ?? account.currency)
.trim()
.toUpperCase();
final symbol = currencySymbolFromCode(currencyCode);
if (symbol != null && symbol.trim().isNotEmpty) {
return '$symbol $amountText';
}
if (currencyCode.isNotEmpty) {
return '$amountText $currencyCode';
}
return amountText;
}

View File

@@ -0,0 +1,19 @@
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
String walletDisplayName(Wallet wallet, AppLocalizations l10n) {
return sourceDisplayName(name: wallet.name, fallback: l10n.paymentTypeWallet);
}
String ledgerDisplayName(LedgerAccount ledger, AppLocalizations l10n) {
return sourceDisplayName(name: ledger.name, fallback: l10n.paymentTypeLedger);
}
String sourceDisplayName({required String name, required String fallback}) {
final normalized = name.trim();
if (normalized.isNotEmpty) return normalized;
return fallback;
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/widgets/payment/source_wallet_selector/balance_formatter.dart';
import 'package:pweb/widgets/payment/source_wallet_selector/display_name.dart';
import 'package:pweb/widgets/payment/source_wallet_selector/options.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
List<DropdownMenuItem<SourceOptionKey>> buildSourceSelectorItems({
required List<Wallet> wallets,
required List<LedgerAccount> ledgerAccounts,
required AppLocalizations l10n,
}) {
return <DropdownMenuItem<SourceOptionKey>>[
...wallets.map((wallet) {
return DropdownMenuItem<SourceOptionKey>(
value: walletOptionKey(wallet.id),
child: Text(
'${walletDisplayName(wallet, l10n)} - ${walletBalance(wallet)}',
overflow: TextOverflow.ellipsis,
),
);
}),
...ledgerAccounts.map((ledger) {
return DropdownMenuItem<SourceOptionKey>(
value: ledgerOptionKey(ledger.ledgerAccountRef),
child: Text(
'${ledgerDisplayName(ledger, l10n)} - ${ledgerBalance(ledger)}',
overflow: TextOverflow.ellipsis,
),
);
}),
];
}

View File

@@ -0,0 +1,25 @@
import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/payment/source_type.dart';
typedef SourceOptionKey = ({PaymentSourceType type, String ref});
SourceOptionKey walletOptionKey(String walletRef) =>
(type: PaymentSourceType.wallet, ref: walletRef);
SourceOptionKey ledgerOptionKey(String ledgerAccountRef) =>
(type: PaymentSourceType.ledger, ref: ledgerAccountRef);
SourceOptionKey? resolveSelectedSourceOption(PaymentSourceController source) {
final selectedWallet = source.selectedWallet;
final selectedLedger = source.selectedLedgerAccount;
return switch (source.selectedType) {
PaymentSourceType.wallet =>
selectedWallet == null ? null : walletOptionKey(selectedWallet.id),
PaymentSourceType.ledger =>
selectedLedger == null
? null
: ledgerOptionKey(selectedLedger.ledgerAccountRef),
null => null,
};
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/widgets/payment/source_wallet_selector/dropdown_items.dart';
import 'package:pweb/widgets/payment/source_wallet_selector/options.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
Widget buildSourceSelectorField({
required BuildContext context,
required List<Wallet> wallets,
required List<LedgerAccount> ledgerAccounts,
required SourceOptionKey? selectedValue,
required ValueChanged<SourceOptionKey> onChanged,
required bool isBusy,
}) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
if (wallets.isEmpty && ledgerAccounts.isEmpty) {
return Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall);
}
final items = buildSourceSelectorItems(
wallets: wallets,
ledgerAccounts: ledgerAccounts,
l10n: l10n,
);
final knownValues = items
.map((item) => item.value)
.whereType<SourceOptionKey>()
.toSet();
final effectiveValue = knownValues.contains(selectedValue)
? selectedValue
: null;
return DropdownButtonFormField<SourceOptionKey>(
initialValue: effectiveValue,
isExpanded: true,
decoration: InputDecoration(
labelText: l10n.whereGetMoney,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
items: items,
onChanged: isBusy
? null
: (value) {
if (value == null) return;
onChanged(value);
},
);
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:pshared/controllers/payment/source.dart';
import 'package:pshared/models/payment/source_type.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/widgets/payment/source_wallet_selector/options.dart';
import 'package:pweb/widgets/payment/source_wallet_selector/selector_field.dart';
class SourceWalletSelector extends StatelessWidget {
const SourceWalletSelector({
super.key,
required this.sourceController,
this.isBusy = false,
this.onChanged,
});
final PaymentSourceController sourceController;
final bool isBusy;
final ValueChanged<Wallet>? onChanged;
@override
Widget build(BuildContext context) {
final source = sourceController;
return buildSourceSelectorField(
context: context,
wallets: source.wallets,
ledgerAccounts: source.ledgerAccounts,
selectedValue: resolveSelectedSourceOption(source),
onChanged: (value) => _onSourceChanged(source, value),
isBusy: isBusy,
);
}
void _onSourceChanged(PaymentSourceController source, SourceOptionKey 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);
}
}
}