added download for operation and included fixes for source of payments #639
34
frontend/pshared/lib/data/dto/payment/operation.dart
Normal file
34
frontend/pshared/lib/data/dto/payment/operation.dart
Normal 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,
|
||||
|
|
||||
this.startedAt,
|
||||
this.completedAt,
|
||||
});
|
||||
|
||||
factory PaymentOperationDTO.fromJson(Map<String, dynamic> json) =>
|
||||
_$PaymentOperationDTOFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$PaymentOperationDTOToJson(this);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
39
frontend/pshared/lib/data/mapper/payment/operation.dart
Normal file
39
frontend/pshared/lib/data/mapper/payment/operation.dart
Normal 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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
25
frontend/pshared/lib/models/payment/execution_operation.dart
Normal file
25
frontend/pshared/lib/models/payment/execution_operation.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Получателю поступит",
|
||||
|
||||
9
frontend/pweb/lib/models/documents/operation.dart
Normal file
9
frontend/pweb/lib/models/documents/operation.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
class OperationDocumentRequestModel {
|
||||
final String gatewayService;
|
||||
final String operationRef;
|
||||
|
||||
const OperationDocumentRequestModel({
|
||||
required this.gatewayService,
|
||||
required this.operationRef,
|
||||
});
|
||||
}
|
||||
6
frontend/pweb/lib/models/payment/source_funds.dart
Normal file
6
frontend/pweb/lib/models/payment/source_funds.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
enum SourceOfFundsVisibleState {
|
||||
headerAction,
|
||||
summary,
|
||||
quoteStatus,
|
||||
sendAction,
|
||||
}
|
||||
9
frontend/pweb/lib/models/report/operation/document.dart
Normal file
9
frontend/pweb/lib/models/report/operation/document.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
class OperationDocumentInfo {
|
||||
final String operationRef;
|
||||
final String gatewayService;
|
||||
|
||||
const OperationDocumentInfo({
|
||||
required this.operationRef,
|
||||
required this.gatewayService,
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) ...[
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
]);
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
23
frontend/pweb/lib/utils/payment/operation_code.dart
Normal file
23
frontend/pweb/lib/utils/payment/operation_code.dart
Normal 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;
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
14
frontend/pweb/lib/utils/report/operations/document_rule.dart
Normal file
14
frontend/pweb/lib/utils/report/operations/document_rule.dart
Normal 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;
|
||||
}
|
||||
12
frontend/pweb/lib/utils/report/operations/state_mapper.dart
Normal file
12
frontend/pweb/lib/utils/report/operations/state_mapper.dart
Normal 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);
|
||||
}
|
||||
15
frontend/pweb/lib/utils/report/operations/time_format.dart
Normal file
15
frontend/pweb/lib/utils/report/operations/time_format.dart
Normal 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;
|
||||
}
|
||||
59
frontend/pweb/lib/utils/report/operations/title_mapper.dart
Normal file
59
frontend/pweb/lib/utils/report/operations/title_mapper.dart
Normal 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(' ');
|
||||
}
|
||||
@@ -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,
|
||||
|
tech
commented
вот, говорил: вот эту штуку хардкодить не надо, лучше вынести куда-то в решающее правило, а не запаковывать внутрь провайдера, где найти ее непросто. вот, говорил: вот эту штуку хардкодить не надо, лучше вынести куда-то в решающее правило, а не запаковывать внутрь провайдера, где найти ее непросто.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
83
frontend/pweb/lib/widgets/payment/source_of_funds_panel.dart
Normal file
83
frontend/pweb/lib/widgets/payment/source_of_funds_panel.dart
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user
Давай на JSON serialisable перейдем