This commit is contained in:
Arseni
2026-03-11 18:26:21 +03:00
parent fdd8dd8845
commit 0172176978
46 changed files with 678 additions and 643 deletions

View File

@@ -1,12 +1,12 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/operation.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
import 'package:pshared/data/dto/payment/payment_quote.dart';
import 'package:pshared/data/dto/payment/response_endpoint.dart';
part 'payment.g.dart';
@JsonSerializable()
class PaymentDTO {
final String? paymentRef;
@@ -15,7 +15,6 @@ class PaymentDTO {
final PaymentResponseEndpointDTO? destination;
final String? failureCode;
final String? failureReason;
final PaymentIntentDTO? intent;
final List<PaymentOperationDTO> operations;
final PaymentQuoteDTO? lastQuote;
final Map<String, String>? metadata;
@@ -28,7 +27,6 @@ class PaymentDTO {
this.destination,
this.failureCode,
this.failureReason,
this.intent,
this.operations = const <PaymentOperationDTO>[],
this.lastQuote,
this.metadata,

View File

@@ -1,11 +1,11 @@
import 'package:pshared/data/dto/payment/payment.dart';
import 'package:pshared/data/mapper/payment/intent/payment.dart';
import 'package:pshared/data/mapper/payment/operation.dart';
import 'package:pshared/data/mapper/payment/quote.dart';
import 'package:pshared/data/mapper/payment/response_endpoint.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/state.dart';
extension PaymentDTOMapper on PaymentDTO {
Payment toDomain() => Payment(
paymentRef: paymentRef,
@@ -15,7 +15,6 @@ extension PaymentDTOMapper on PaymentDTO {
orchestrationState: paymentOrchestrationStateFromValue(state),
failureCode: failureCode,
failureReason: failureReason,
intent: intent?.toDomain(),
operations: operations.map((item) => item.toDomain()).toList(),
lastQuote: lastQuote?.toDomain(),
metadata: metadata,
@@ -31,7 +30,6 @@ extension PaymentMapper on Payment {
destination: destination?.toDTO(),
failureCode: failureCode,
failureReason: failureReason,
intent: intent?.toDTO(),
operations: operations.map((item) => item.toDTO()).toList(),
lastQuote: lastQuote?.toDTO(),
metadata: metadata,

View File

@@ -1,9 +1,9 @@
import 'package:pshared/models/payment/endpoint.dart';
import 'package:pshared/models/payment/execution_operation.dart';
import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/quote/quote.dart';
import 'package:pshared/models/payment/state.dart';
class Payment {
final String? paymentRef;
final String? state;
@@ -12,7 +12,6 @@ class Payment {
final PaymentOrchestrationState orchestrationState;
final String? failureCode;
final String? failureReason;
final PaymentIntent? intent;
final List<PaymentExecutionOperation> operations;
final PaymentQuote? lastQuote;
final Map<String, String>? metadata;
@@ -26,7 +25,6 @@ class Payment {
required this.orchestrationState,
required this.failureCode,
required this.failureReason,
this.intent,
required this.operations,
required this.lastQuote,
required this.metadata,

View File

@@ -4,6 +4,16 @@ import 'package:pshared/models/asset.dart';
import 'package:pshared/models/currency.dart';
const nonBreakingSpace = '\u00A0';
String withTrailingNonBreakingSpace(String value) {
return '$value$nonBreakingSpace';
}
String joinWithNonBreakingSpace(String left, String right) {
return '$left$nonBreakingSpace$right';
}
String currencyCodeToSymbol(Currency currencyCode) {
switch (currencyCode) {
case Currency.usd:
@@ -24,7 +34,10 @@ String amountToString(double amount) {
}
String currencyToString(Currency currencyCode, double amount) {
return '${currencyCodeToSymbol(currencyCode)}\u00A0${amountToString(amount)}';
return joinWithNonBreakingSpace(
currencyCodeToSymbol(currencyCode),
amountToString(amount),
);
}
String assetToString(Asset asset) {

View File

@@ -1,4 +1,5 @@
import 'package:pshared/models/money.dart';
import 'package:pshared/utils/currency.dart';
double parseMoneyAmount(String? raw, {double fallback = 0}) {
@@ -7,6 +8,34 @@ double parseMoneyAmount(String? raw, {double fallback = 0}) {
return double.tryParse(trimmed) ?? fallback;
}
String formatMoneyDisplay(
Money? money, {
String fallback = '--',
String separator = ' ',
String invalidAmountFallback = '',
}) {
if (money == null) return fallback;
final rawAmount = money.amount.trim();
final rawCurrency = money.currency.trim();
final parsedAmount = parseMoneyAmount(rawAmount, fallback: double.nan);
final amountToken = parsedAmount.isNaN
? (rawAmount.isEmpty ? invalidAmountFallback : rawAmount)
: amountToString(parsedAmount);
final symbol = currencySymbolFromCode(rawCurrency);
final normalizedSymbol = symbol?.trim() ?? '';
final hasSymbol = normalizedSymbol.isNotEmpty;
final currencyToken = hasSymbol ? normalizedSymbol : rawCurrency;
final first = amountToken;
final second = currencyToken;
if (first.isEmpty && second.isEmpty) return fallback;
if (first.isEmpty) return second;
if (second.isEmpty) return first;
return '$first$separator$second';
}
extension MoneyAmountX on Money {
double get amountValue => parseMoneyAmount(amount);
}

View File

@@ -91,16 +91,22 @@ class BalanceSourceActionsController {
}
void _openWalletOperationHistory(BuildContext context, String walletRef) {
context.read<PaymentSourceController>().selectWalletByRef(walletRef);
context.pushNamed(PayoutRoutes.editWallet);
_withSelectedWallet(
context,
walletRef,
() => context.pushNamed(PayoutRoutes.editWallet),
);
}
void _sendWalletPayout(BuildContext context, String walletRef) {
context.read<PaymentSourceController>().selectWalletByRef(walletRef);
context.pushNamed(
PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: PaymentType.wallet,
_withSelectedWallet(
context,
walletRef,
() => context.pushNamed(
PayoutRoutes.payment,
queryParameters: PayoutRoutes.buildQueryParameters(
paymentType: PaymentType.wallet,
),
),
);
}
@@ -114,4 +120,13 @@ class BalanceSourceActionsController {
),
);
}
void _withSelectedWallet(
BuildContext context,
String walletRef,
VoidCallback action,
) {
context.read<PaymentSourceController>().selectWalletByRef(walletRef);
action();
}
}

View File

@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class BalanceCopyState {
final String label;
@@ -13,23 +16,26 @@ class BalanceCopyState {
class BalanceSourceCopyController {
const BalanceSourceCopyController();
BalanceCopyState wallet(String? depositAddress) {
return BalanceCopyState(
label: 'Copy Deposit Address',
payload: depositAddress?.trim() ?? '',
);
}
BalanceCopyState wallet(BuildContext context, String? depositAddress) =>
_buildCopyAddressState(context, depositAddress);
BalanceCopyState ledger(String? accountCode) {
return BalanceCopyState(
label: 'Copy Deposit Address',
payload: accountCode?.trim() ?? '',
);
}
BalanceCopyState ledger(BuildContext context, String? accountCode) =>
_buildCopyAddressState(context, accountCode);
Future<bool> copy(BalanceCopyState state) async {
if (!state.canCopy) return false;
await Clipboard.setData(ClipboardData(text: state.payload));
return true;
}
BalanceCopyState _buildCopyAddressState(
BuildContext context,
String? payload,
) {
final l10n = AppLocalizations.of(context)!;
return BalanceCopyState(
label: l10n.copyAddress,
payload: payload?.trim() ?? '',
);
}
}

View File

@@ -1,7 +1,7 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/source_type.dart';
@@ -131,16 +131,14 @@ class ReportOperationsController extends ChangeNotifier {
bool _matchesCurrentSource(Payment payment) {
final sourceType = _sourceType;
if (sourceType == null || _sourceRefs.isEmpty) return true;
for (final sourceRef in _sourceRefs) {
if (paymentMatchesSource(
payment,
sourceType: sourceType,
sourceRef: sourceRef,
)) {
return true;
}
}
return false;
return _sourceRefs.firstWhereOrNull(
(sourceRef) => paymentMatchesSource(
payment,
sourceType: sourceType,
sourceRef: sourceRef,
),
) !=
null;
}
Set<String> _normalizeRefs(List<String> refs) {

View File

@@ -235,6 +235,7 @@
"avatarUpdateError": "Failed to update profile photo",
"settings": "Settings",
"notSet": "not set",
"valueUnavailable": "Not available",
"search": "Search...",
"ok": "Ok",
"cancel": "Cancel",
@@ -393,7 +394,7 @@
"paymentDetailsNotFound": "Payment not found",
"paymentDetailsIdentifiers": "Identifiers",
"paymentDetailsAmounts": "Amounts",
"paymentDetailsFx": "FX quote",
"paymentDetailsFx": "Conversion rate",
"paymentDetailsFailure": "Failure",
"paymentDetailsMetadata": "Metadata",
"metadataUploadFileName": "Upload file name",
@@ -424,7 +425,7 @@
"paymentOperationSettlement": "Settlement",
"paymentOperationLedger": "Ledger",
"paymentOperationActionSend": "Send",
"paymentOperationActionObserve": "Observe",
"paymentOperationActionObserve": "Confirmation",
"paymentOperationActionFxConvert": "FX convert",
"paymentOperationActionCredit": "Credit",
"paymentOperationActionBlock": "Block",

View File

@@ -235,6 +235,7 @@
"avatarUpdateError": "Не удалось обновить фото профиля",
"settings": "Настройки",
"notSet": "не задано",
"valueUnavailable": "Недоступно",
"search": "Поиск...",
"ok": "Ок",
"cancel": "Отмена",
@@ -393,7 +394,7 @@
"paymentDetailsNotFound": "Платеж не найден",
"paymentDetailsIdentifiers": "Идентификаторы",
"paymentDetailsAmounts": "Суммы",
"paymentDetailsFx": "Курс",
"paymentDetailsFx": "Курс обмена",
"paymentDetailsFailure": "Ошибка",
"paymentDetailsMetadata": "Метаданные",
"metadataUploadFileName": "Имя файла загрузки",
@@ -424,7 +425,7 @@
"paymentOperationSettlement": "Расчётный контур",
"paymentOperationLedger": "Леджер",
"paymentOperationActionSend": "Отправка",
"paymentOperationActionObserve": "Проверка",
"paymentOperationActionObserve": "Подтверждение",
"paymentOperationActionFxConvert": "FX-конверсия",
"paymentOperationActionCredit": "Зачисление",
"paymentOperationActionBlock": "Блокировка",

View File

@@ -7,7 +7,7 @@ import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/pages/address_book/form/widgets/feilds/email.dart';
import 'package:pweb/pages/address_book/form/widgets/header.dart';
import 'package:pweb/pages/address_book/form/widgets/feilds/name.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel/panel.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/selector_row.dart';
import 'package:pweb/pages/address_book/form/widgets/save_button.dart';

View File

@@ -1,115 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/pages/payment_methods/form.dart';
import 'package:pweb/pages/payment_methods/icon.dart';
import 'package:pweb/widgets/dialogs/confirmation_dialog.dart';
import 'package:pweb/models/state/control_state.dart';
import 'package:pweb/models/state/visibility.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentMethodPanel extends StatelessWidget {
final PaymentType selectedType;
final int selectedIndex;
final List<RecipientMethodDraft> entries;
final ValueChanged<int> onRemove;
final void Function(int, PaymentMethodData) onChanged;
final ControlState editState;
final VisibilityState deleteVisibility;
final double padding;
const PaymentMethodPanel({
super.key,
required this.selectedType,
required this.selectedIndex,
required this.entries,
required this.onRemove,
required this.onChanged,
this.editState = ControlState.enabled,
this.deleteVisibility = VisibilityState.visible,
this.padding = 16,
});
Future<void> _confirmDelete(BuildContext context, VoidCallback onConfirmed) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showConfirmationDialog(
context: context,
title: l10n.delete,
message: l10n.deletePaymentConfirmation,
confirmLabel: l10n.delete,
);
if (confirmed) {
onConfirmed();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
final label = l10n.paymentMethodDetails;
final entry = selectedIndex >= 0 && selectedIndex < entries.length
? entries[selectedIndex]
: null;
return AnimatedContainer(
duration: const Duration(milliseconds: 3000),
padding: EdgeInsets.all(padding),
decoration: BoxDecoration(
color: theme.colorScheme.onSecondary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.colorScheme.onSurface.withAlpha(30)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
iconForPaymentType(selectedType),
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (entry != null && deleteVisibility == VisibilityState.visible)
TextButton.icon(
onPressed: () => _confirmDelete(context, () => onRemove(selectedIndex)),
icon: Icon(Icons.delete, color: theme.colorScheme.error),
label: Text(
l10n.delete,
style: TextStyle(color: theme.colorScheme.error),
),
),
],
),
const SizedBox(height: 12),
if (entry != null)
PaymentMethodForm(
key: ValueKey('${selectedType.name}-${entry.existing?.id ?? selectedIndex}-form'),
selectedType: selectedType,
initialData: entry.data,
isEditable: editState == ControlState.enabled,
onChanged: (data) {
if (data == null) return;
onChanged(selectedIndex, data);
},
),
],
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/models/state/control_state.dart';
import 'package:pweb/models/state/visibility.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel/panel_container.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel/panel_entry_form.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel/panel_header.dart';
import 'package:pweb/utils/payment/method_delete_confirmation.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentMethodPanel extends StatelessWidget {
final PaymentType selectedType;
final int selectedIndex;
final List<RecipientMethodDraft> entries;
final ValueChanged<int>? onRemove;
final void Function(int, PaymentMethodData)? onChanged;
final ControlState editState;
final VisibilityState deleteVisibility;
final double padding;
const PaymentMethodPanel({
super.key,
required this.selectedType,
required this.selectedIndex,
required this.entries,
this.onRemove,
this.onChanged,
this.editState = ControlState.enabled,
this.deleteVisibility = VisibilityState.visible,
this.padding = 16,
}) : assert(editState == ControlState.disabled || onChanged != null),
assert(deleteVisibility == VisibilityState.hidden || onRemove != null);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final entry = selectedIndex >= 0 && selectedIndex < entries.length
? entries[selectedIndex]
: null;
final showDelete =
entry != null &&
deleteVisibility == VisibilityState.visible &&
onRemove != null;
return PaymentMethodPanelContainer(
padding: padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PaymentMethodPanelHeader(
selectedType: selectedType,
title: l10n.paymentMethodDetails,
deleteLabel: l10n.delete,
showDelete: showDelete,
onDelete: showDelete
? () => confirmPaymentMethodDelete(
context,
() => onRemove!(selectedIndex),
)
: null,
),
const SizedBox(height: 12),
if (entry != null)
PaymentMethodPanelEntryForm(
selectedType: selectedType,
selectedIndex: selectedIndex,
entry: entry,
isEditable: editState == ControlState.enabled,
onChanged: onChanged == null
? null
: (data) => onChanged!(selectedIndex, data),
),
],
),
);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
class PaymentMethodPanelContainer extends StatelessWidget {
final double padding;
final Widget child;
const PaymentMethodPanelContainer({
super.key,
required this.padding,
required this.child,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedContainer(
duration: const Duration(milliseconds: 3000),
padding: EdgeInsets.all(padding),
decoration: BoxDecoration(
color: theme.colorScheme.onSecondary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.colorScheme.onSurface.withAlpha(30)),
),
child: child,
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/pages/payment_methods/form.dart';
class PaymentMethodPanelEntryForm extends StatelessWidget {
final PaymentType selectedType;
final int selectedIndex;
final RecipientMethodDraft entry;
final bool isEditable;
final ValueChanged<PaymentMethodData>? onChanged;
const PaymentMethodPanelEntryForm({
super.key,
required this.selectedType,
required this.selectedIndex,
required this.entry,
required this.isEditable,
this.onChanged,
});
@override
Widget build(BuildContext context) {
return PaymentMethodForm(
key: ValueKey(
'${selectedType.name}-${entry.existing?.id ?? selectedIndex}-form',
),
selectedType: selectedType,
initialData: entry.data,
isEditable: isEditable,
onChanged: (data) {
if (data == null) return;
onChanged?.call(data);
},
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pweb/pages/payment_methods/icon.dart';
class PaymentMethodPanelHeader extends StatelessWidget {
final PaymentType selectedType;
final String title;
final String deleteLabel;
final bool showDelete;
final VoidCallback? onDelete;
const PaymentMethodPanelHeader({
super.key,
required this.selectedType,
required this.title,
required this.deleteLabel,
required this.showDelete,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Icon(
iconForPaymentType(selectedType),
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
if (showDelete && onDelete != null)
TextButton.icon(
onPressed: onDelete,
icon: Icon(Icons.delete, color: theme.colorScheme.error),
label: Text(
deleteLabel,
style: TextStyle(color: theme.colorScheme.error),
),
),
],
);
}
}

View File

@@ -3,9 +3,12 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/utils/money_display.dart';
class BalanceAmount extends StatelessWidget {
final Wallet wallet;
@@ -25,6 +28,13 @@ class BalanceAmount extends StatelessWidget {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final currencyBalance = currencyCodeToSymbol(wallet.currency);
final formattedBalance = formatMoneyUi(
context,
Money(
amount: amountToString(wallet.balance),
currency: currencyCodeToString(wallet.currency),
),
);
final wallets = context.watch<WalletsController>();
final isMasked = wallets.isBalanceMasked(wallet.id);
@@ -32,9 +42,7 @@ class BalanceAmount extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
Text(
isMasked
? '•••• $currencyBalance'
: '${amountToString(wallet.balance)} $currencyBalance',
isMasked ? '•••• $currencyBalance' : formattedBalance,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.headlineMedium?.copyWith(

View File

@@ -6,8 +6,8 @@ import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/models/dashboard/balance_item.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/card.dart';
import 'package:pweb/pages/dashboard/buttons/balance/config.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/card.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/cards/ledger.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/cards/wallet.dart';
class BalanceCarouselCardItem extends StatelessWidget {
@@ -29,9 +29,9 @@ class BalanceCarouselCardItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final card = switch (item) {
WalletBalanceItem(:final wallet) => WalletCard(
WalletBalanceItem(:final wallet) => BalanceSourceCard.wallet(
wallet: wallet,
onTopUp: () => onTopUp(wallet),
onAddFunds: () => onTopUp(wallet),
onTap: () => onWalletTap(wallet),
),
LedgerBalanceItem(:final account) => LedgerAccountCard(

View File

@@ -23,7 +23,7 @@ class LedgerBalanceAmount extends StatelessWidget {
final isMasked = controller.isBalanceMasked(account.ledgerAccountRef);
final balance = isMasked
? LedgerBalanceFormatter.formatMasked(account)
: LedgerBalanceFormatter.format(account);
: LedgerBalanceFormatter.format(context, account);
return Row(
mainAxisSize: MainAxisSize.min,

View File

@@ -61,7 +61,7 @@ class BalanceSourceCard extends StatelessWidget {
? null
: wallet.network!.localizedName(context);
final symbol = wallet.tokenSymbol?.trim();
final copyState = _copyController.wallet(wallet.depositAddress);
final copyState = _copyController.wallet(context, wallet.depositAddress);
return BalanceSourceCardLayout(
title: wallet.name,
@@ -105,7 +105,7 @@ class BalanceSourceCard extends StatelessWidget {
final badge = account.currency.trim().isEmpty
? null
: account.currency.toUpperCase();
final copyState = _copyController.ledger(accountCode);
final copyState = _copyController.ledger(context, accountCode);
return BalanceSourceCardLayout(
title: title,

View File

@@ -1,28 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/dashboard/buttons/balance/source/card.dart';
class WalletCard extends StatelessWidget {
final Wallet wallet;
final VoidCallback onTopUp;
final VoidCallback onTap;
const WalletCard({
super.key,
required this.wallet,
required this.onTopUp,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return BalanceSourceCard.wallet(
wallet: wallet,
onTap: onTap,
onAddFunds: onTopUp,
);
}
}

View File

@@ -36,7 +36,9 @@ class PaymentAmountField extends StatelessWidget {
decoration: InputDecoration(
labelText: loc.amount,
border: const OutlineInputBorder(),
prefixText: symbol == null ? null : '$symbol\u00A0',
prefixText: symbol == null
? null
: withTrailingNonBreakingSpace(symbol),
),
onChanged: ui.handleChanged,
),

View File

@@ -1,37 +0,0 @@
import 'package:pshared/models/asset.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
String moneyLabel(Money? money) {
if (money == null) return 'N/A';
final amount = parseMoneyAmount(money.amount, fallback: double.nan);
if (amount.isNaN) return '${money.amount} ${money.currency}';
try {
return assetToString(
Asset(currency: currencyStringToCode(money.currency), amount: amount),
);
} catch (_) {
return '${money.amount} ${money.currency}';
}
}
String sentAmountLabel(MultiplePayoutsController controller) {
final requested = controller.requestedSentAmount;
final sourceDebit = controller.aggregateDebitAmount;
if (requested == null && sourceDebit == null) return 'N/A';
if (sourceDebit != null) return moneyLabel(sourceDebit);
return moneyLabel(requested);
}
String feeLabel(MultiplePayoutsController controller, AppLocalizations l10n) {
final fee = controller.aggregateFeeAmount;
if (fee == null) return l10n.noFee;
return moneyLabel(fee);
}

View File

@@ -1,11 +1,15 @@
import 'package:flutter/material.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/controllers/payouts/multiple_payouts.dart';
import 'package:pweb/models/dashboard/summary_values.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart';
import 'package:pweb/pages/dashboard/payouts/summary/widget.dart';
import 'package:pweb/utils/money_display.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SourceQuoteSummary extends StatelessWidget {
const SourceQuoteSummary({
super.key,
@@ -18,12 +22,27 @@ class SourceQuoteSummary extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return PaymentSummary(
spacing: spacing,
values: PaymentSummaryValues(
fee: feeLabel(controller, AppLocalizations.of(context)!),
recipientReceives: moneyLabel(controller.aggregateSettlementAmount),
total: moneyLabel(controller.aggregateDebitAmount),
fee: controller.aggregateFeeAmount == null
? l10n.noFee
: formatMoneyUiWithL10n(
l10n,
controller.aggregateFeeAmount,
separator: nonBreakingSpace,
),
recipientReceives: formatMoneyUiWithL10n(
l10n,
controller.aggregateSettlementAmount,
separator: nonBreakingSpace,
),
total: formatMoneyUiWithL10n(
l10n,
controller.aggregateDebitAmount,
separator: nonBreakingSpace,
),
),
);
}

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/asset.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/utils/money_display.dart';
class PaymentSummaryRow extends StatelessWidget {
@@ -11,8 +12,8 @@ class PaymentSummaryRow extends StatelessWidget {
final TextStyle? style;
const PaymentSummaryRow({
super.key,
required this.labelFactory,
super.key,
required this.labelFactory,
required this.asset,
this.value,
this.style,
@@ -20,8 +21,7 @@ class PaymentSummaryRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final formatted = value ??
(asset == null ? 'N/A' : assetToString(asset!));
final formatted = value ?? formatAssetUi(context, asset);
return Text(labelFactory(formatted), style: style);
}
}

View File

@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel/panel.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/header.dart';
import 'package:pweb/models/state/control_state.dart';
import 'package:pweb/models/state/visibility.dart';
@@ -40,8 +40,6 @@ class PaymentInfoManualDetailsSection extends StatelessWidget {
selectedType: data.type,
selectedIndex: 0,
entries: [entry],
onRemove: (_) {},
onChanged: (_, ignored) {},
editState: ControlState.disabled,
deleteVisibility: VisibilityState.hidden,
),

View File

@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/payment_method_draft.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel/panel.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/panel.dart';
import 'package:pweb/pages/address_book/form/widgets/payment_methods/selector_row.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/details_builder.dart';
import 'package:pweb/pages/payout_page/send/widgets/payment_info/header.dart';
@@ -78,8 +78,6 @@ class PaymentInfoMethodsSection extends StatelessWidget {
selectedType: state.selectedType,
selectedIndex: state.selectedIndex!,
entries: state.selectedEntries,
onRemove: (_) {},
onChanged: (_, _) {},
editState: ControlState.disabled,
deleteVisibility: VisibilityState.hidden,
),

View File

@@ -1,30 +1,16 @@
import 'package:flutter/widgets.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/utils/money_display.dart';
class LedgerBalanceFormatter {
const LedgerBalanceFormatter._();
static String format(LedgerAccount account) {
final money = account.balance?.balance;
if (money == null) return '--';
final amount = parseMoneyAmount(money.amount, fallback: double.nan);
if (amount.isNaN) {
return '${money.amount} ${money.currency}';
}
try {
final currency = currencyStringToCode(money.currency);
final symbol = currencyCodeToSymbol(currency);
if (symbol.trim().isEmpty) {
return '${amountToString(amount)} ${money.currency}';
}
return '${amountToString(amount)} $symbol';
} catch (_) {
return '${amountToString(amount)} ${money.currency}';
}
static String format(BuildContext context, LedgerAccount account) {
return formatMoneyUi(context, account.balance?.balance);
}
static String formatMasked(LedgerAccount account) {

View File

@@ -26,7 +26,7 @@ class LedgerSection extends StatelessWidget {
final hasAccountCode = accountCode.isNotEmpty;
final balance = isMasked
? LedgerBalanceFormatter.formatMasked(ledger)
: LedgerBalanceFormatter.format(ledger);
: LedgerBalanceFormatter.format(context, ledger);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -1,91 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/models/wallet/wallet_transaction.dart';
import 'package:pweb/pages/payout_page/wallet/history/chip.dart';
import 'package:pweb/pages/report/table/badge.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class WalletTransactionsTable extends StatelessWidget {
final List<WalletTransaction> transactions;
const WalletTransactionsTable({super.key, required this.transactions});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!;
if (transactions.isEmpty) {
return Card(
color: theme.colorScheme.onSecondary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(loc.walletHistoryEmpty),
),
);
}
return Card(
color: theme.colorScheme.onSecondary,
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(12),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columnSpacing: 18,
headingTextStyle: const TextStyle(fontWeight: FontWeight.w600),
columns: [
DataColumn(label: Text(loc.colStatus)),
DataColumn(label: Text(loc.colType)),
DataColumn(label: Text(loc.colAmount)),
DataColumn(label: Text(loc.colBalance)),
DataColumn(label: Text(loc.colCounterparty)),
DataColumn(label: Text(loc.colDate)),
DataColumn(label: Text(loc.colComment)),
],
rows: List.generate(
transactions.length,
(index) {
final tx = transactions[index];
final color = WidgetStateProperty.resolveWith<Color?>(
(states) => index.isEven
? theme.colorScheme.surfaceContainerHighest
: null,
);
return DataRow.byIndex(
index: index,
color: color,
cells: [
DataCell(OperationStatusBadge(status: tx.status)),
DataCell(TypeChip(type: tx.type)),
DataCell(Text(
'${tx.type.sign}${amountToString(tx.amount)} ${currencyCodeToSymbol(tx.currency)}')),
DataCell(Text(
tx.balanceAfter == null
? '-'
: '${amountToString(tx.balanceAfter!)} ${currencyCodeToSymbol(tx.currency)}',
)),
DataCell(Text(tx.counterparty ?? '-')),
DataCell(Text(
'${TimeOfDay.fromDateTime(tx.date).format(context)}\n'
'${tx.date.toLocal().toIso8601String().split("T").first}',
)),
DataCell(Text(tx.description)),
],
);
},
),
),
),
),
);
}
}

View File

@@ -15,19 +15,23 @@ class OperationCard extends StatelessWidget {
final OperationItem operation;
final ValueChanged<OperationItem>? onTap;
const OperationCard({
super.key,
required this.operation,
this.onTap,
});
const OperationCard({super.key, required this.operation, this.onTap});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final canOpen = onTap != null && paymentIdFromOperation(operation) != null;
final amountLabel = formatAmount(operation.amount, operation.currency);
final toAmountLabel = formatAmount(operation.toAmount, operation.toCurrency);
final amountLabel = formatAmount(
context,
operation.amount,
operation.currency,
);
final toAmountLabel = formatAmount(
context,
operation.toAmount,
operation.toCurrency,
);
final showToAmount = shouldShowToAmount(operation);
final timeLabel = formatOperationTime(context, operation.date);

View File

@@ -42,6 +42,7 @@ class PayoutTotalsList extends StatelessWidget {
const SizedBox(width: 8),
Text(
formatAmount(
context,
totals[index].amount,
totals[index].currency,
),

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/fx/quote.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/pages/report/details/section.dart';
import 'package:pweb/pages/report/details/sections/rows.dart';
@@ -13,26 +15,17 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentFxSection extends StatelessWidget {
final Payment payment;
const PaymentFxSection({
super.key,
required this.payment,
});
const PaymentFxSection({super.key, required this.payment});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final fx = payment.lastQuote?.fxQuote;
final rows = buildDetailRows([
DetailValue(
label: loc.fxRateLabel,
value: _formatRate(fx),
),
DetailValue(label: loc.fxRateLabel, value: _formatRate(fx)),
]);
return DetailsSection(
title: loc.paymentDetailsFx,
children: rows,
);
return DetailsSection(title: loc.paymentDetailsFx, children: rows);
}
String? _formatRate(FxQuote? fx) {
@@ -40,24 +33,33 @@ class PaymentFxSection extends StatelessWidget {
final price = fx.price?.trim();
if (price == null || price.isEmpty) return null;
final base = _firstNonEmpty([
currencySymbolFromCode(fx.baseCurrency),
currencySymbolFromCode(fx.baseAmount?.currency),
final baseCurrency = _firstNonEmpty([
fx.baseCurrency,
fx.baseAmount?.currency,
currencySymbolFromCode(fx.baseCurrency),
currencySymbolFromCode(fx.baseAmount?.currency),
]);
final quote = _firstNonEmpty([
currencySymbolFromCode(fx.quoteCurrency),
currencySymbolFromCode(fx.quoteAmount?.currency),
final quoteCurrency = _firstNonEmpty([
fx.quoteCurrency,
fx.quoteAmount?.currency,
currencySymbolFromCode(fx.quoteCurrency),
currencySymbolFromCode(fx.quoteAmount?.currency),
]);
if (base == null || quote == null) {
return price;
}
if (baseCurrency == null || quoteCurrency == null) return price;
return '1 $base = $price $quote';
final baseDisplay = formatMoneyDisplay(
Money(amount: '1', currency: baseCurrency),
fallback: '1 $baseCurrency',
invalidAmountFallback: '1',
);
final quoteDisplay = formatMoneyDisplay(
Money(amount: _normalizeAmount(price), currency: quoteCurrency),
fallback: '$price $quoteCurrency',
invalidAmountFallback: price,
);
return '$baseDisplay = $quoteDisplay';
}
String? _firstNonEmpty(List<String?> values) {
@@ -67,4 +69,8 @@ class PaymentFxSection extends StatelessWidget {
}
return null;
}
String _normalizeAmount(String raw) {
return raw.replaceAll(RegExp(r'\s+'), '').replaceAll(',', '.');
}
}

View File

@@ -1,67 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/pages/report/details/row.dart';
import 'package:pweb/pages/report/details/section.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentMetadataSection extends StatelessWidget {
final Payment payment;
const PaymentMetadataSection({
super.key,
required this.payment,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final metadata = payment.metadata ?? const {};
const allowedKeys = {'upload_filename', 'upload_rows'};
final filtered = Map<String, String>.fromEntries(
metadata.entries.where((entry) => allowedKeys.contains(entry.key)),
);
if (filtered.isEmpty) {
return DetailsSection(
title: loc.paymentDetailsMetadata,
children: [
Text(
loc.metadataEmpty,
style: Theme.of(context).textTheme.bodyMedium,
),
],
);
}
final entries = filtered.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
return DetailsSection(
title: loc.paymentDetailsMetadata,
children: entries
.map(
(entry) => DetailRow(
label: _metadataLabel(loc, entry.key),
value: entry.value,
monospaced: true,
),
)
.toList(),
);
}
}
String _metadataLabel(AppLocalizations loc, String key) {
switch (key) {
case 'upload_filename':
return loc.metadataUploadFileName;
case 'upload_rows':
return loc.metadataTotalRecipients;
default:
return key;
}
}

View File

@@ -27,6 +27,7 @@ class OperationHistoryTile extends StatelessWidget {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final title = resolveOperationTitle(loc, operation.code);
final operationLabel = operation.label?.trim();
final stateView = resolveStepStateView(context, operation.state);
final completedAt = formatCompletedAt(context, operation.completedAt);
final canDownload = canDownloadDocument && onDownloadDocument != null;
@@ -49,13 +50,24 @@ class OperationHistoryTile extends StatelessWidget {
StepStateChip(view: stateView),
],
),
if (operationLabel != null &&
operationLabel.isNotEmpty &&
operationLabel != title) ...[
const SizedBox(height: 4),
Text(
operationLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
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(

View File

@@ -9,6 +9,7 @@ import 'package:pweb/pages/report/details/summary_card/info_line.dart';
import 'package:pweb/pages/report/table/badge.dart';
import 'package:pweb/utils/report/amount_parts.dart';
import 'package:pweb/utils/report/format.dart';
import 'package:pweb/utils/money_display.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
import 'package:pweb/utils/clipboard.dart';
@@ -24,6 +25,7 @@ class PaymentSummaryCard extends StatelessWidget {
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final unavailableValue = unavailableMoneyValue(context);
final status = statusFromPayment(payment);
final dateLabel = formatDateLabel(context, resolvePaymentDate(payment));
@@ -33,14 +35,16 @@ class PaymentSummaryCard extends StatelessWidget {
final toAmount = payment.lastQuote?.amounts?.destinationSettlement;
final fee = quoteFeeTotal(payment.lastQuote);
final amountLabel = formatMoney(primaryAmount);
final toAmountLabel = formatMoney(toAmount);
final feeLabel = formatMoney(fee);
final amountLabel = formatMoney(context, primaryAmount);
final toAmountLabel = formatMoney(context, toAmount);
final feeLabel = formatMoney(context, fee);
final paymentRef = (payment.paymentRef ?? '').trim();
final showToAmount = toAmountLabel != '-';
final showToAmount = toAmountLabel != unavailableValue;
final showFee = payment.lastQuote != null;
final feeText = feeLabel != '-' ? loc.fee(feeLabel) : loc.fee(loc.noFee);
final feeText = feeLabel != unavailableValue
? loc.fee(feeLabel)
: loc.fee(loc.noFee);
final showPaymentId = paymentRef.isNotEmpty;
final amountParts = splitAmount(amountLabel);
@@ -73,12 +77,12 @@ class PaymentSummaryCard extends StatelessWidget {
currency: amountParts.currency,
),
const SizedBox(height: 6),
if (amountLabel != '-')
if (amountLabel != unavailableValue)
InfoLine(
icon: Icons.send_outlined,
text: loc.sentAmount(amountLabel),
),
if (showToAmount && toAmountLabel != '-')
if (showToAmount && toAmountLabel != unavailableValue)
InfoLine(
icon: Icons.south_east,
text: loc.recipientWillReceive(toAmountLabel),

View File

@@ -1,14 +1,17 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pshared/models/payment/status.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/pages/report/table/badge.dart';
import 'package:pweb/utils/money_display.dart';
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;
@@ -35,13 +38,21 @@ class OperationRow {
label: Text(loc.downloadAct),
)
: Text(op.fileName ?? '');
final amountLabel = formatMoneyUiWithL10n(
loc,
Money(amount: amountToString(op.amount), currency: op.currency),
);
final toAmountLabel = formatMoneyUiWithL10n(
loc,
Money(amount: amountToString(op.toAmount), currency: op.toCurrency),
);
return DataRow(
cells: [
DataCell(OperationStatusBadge(status: op.status)),
DataCell(documentCell),
DataCell(Text('${amountToString(op.amount)} ${op.currency}')),
DataCell(Text('${amountToString(op.toAmount)} ${op.toCurrency}')),
DataCell(Text(amountLabel)),
DataCell(Text(toAmountLabel)),
DataCell(Text(op.payId)),
DataCell(Text(op.cardNumber ?? '-')),
DataCell(Text(op.name)),

View File

@@ -0,0 +1,96 @@
import 'package:flutter/widgets.dart';
import 'package:pshared/models/asset.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
String unavailableMoneyValue(BuildContext context) {
return AppLocalizations.of(context)!.valueUnavailable;
}
String unavailableMoneyValueFromL10n(AppLocalizations l10n) {
return l10n.valueUnavailable;
}
String formatMoneyUi(
BuildContext context,
Money? money, {
String separator = ' ',
}) {
return formatMoneyUiWithL10n(
AppLocalizations.of(context)!,
money,
separator: separator,
);
}
String formatMoneyUiWithL10n(
AppLocalizations l10n,
Money? money, {
String separator = ' ',
}) {
final unavailableValue = unavailableMoneyValueFromL10n(l10n);
return formatMoneyDisplay(
money,
fallback: unavailableValue,
invalidAmountFallback: unavailableValue,
separator: separator,
);
}
String formatAmountUi(
BuildContext context, {
required double amount,
required String currency,
String separator = ' ',
}) {
return formatAmountUiWithL10n(
AppLocalizations.of(context)!,
amount: amount,
currency: currency,
separator: separator,
);
}
String formatAmountUiWithL10n(
AppLocalizations l10n, {
required double amount,
required String currency,
String separator = ' ',
}) {
return formatMoneyUiWithL10n(
l10n,
Money(amount: amountToString(amount), currency: currency),
separator: separator,
);
}
String formatAssetUi(
BuildContext context,
Asset? asset, {
String separator = ' ',
}) {
return formatAssetUiWithL10n(
AppLocalizations.of(context)!,
asset,
separator: separator,
);
}
String formatAssetUiWithL10n(
AppLocalizations l10n,
Asset? asset, {
String separator = ' ',
}) {
if (asset == null) return unavailableMoneyValueFromL10n(l10n);
return formatAmountUiWithL10n(
l10n,
amount: asset.amount,
currency: currencyCodeToString(asset.currency),
separator: separator,
);
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:pweb/widgets/dialogs/confirmation_dialog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
Future<void> confirmPaymentMethodDelete(
BuildContext context,
VoidCallback onConfirmed,
) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showConfirmationDialog(
context: context,
title: l10n.delete,
message: l10n.deletePaymentConfirmation,
confirmLabel: l10n.delete,
);
if (confirmed) {
onConfirmed();
}
}

View File

@@ -50,31 +50,35 @@ StatusView operationStatusViewFromToken(
case 'settled':
return StatusView(
label: l10n.operationStatusSuccessful,
backgroundColor: scheme.tertiaryContainer,
foregroundColor: scheme.onTertiaryContainer,
backgroundColor: Colors.green,
foregroundColor: Colors.white,
);
case 'skipped':
return StatusView(
label: l10n.operationStepStateSkipped,
backgroundColor: scheme.secondaryContainer,
foregroundColor: scheme.onSecondaryContainer,
backgroundColor: Colors.grey,
foregroundColor: Colors.white,
);
case 'error':
case 'failed':
case 'rejected':
case 'aborted':
return StatusView(
label: l10n.operationStatusUnsuccessful,
backgroundColor: scheme.errorContainer,
foregroundColor: scheme.onErrorContainer,
backgroundColor: Colors.red,
foregroundColor: Colors.white,
);
case 'cancelled':
case 'canceled':
return StatusView(
label: l10n.paymentStatusCancelled,
backgroundColor: scheme.surfaceContainerHighest,
foregroundColor: scheme.onSurfaceVariant,
backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
);
case 'processing':
case 'running':
case 'executing':
@@ -82,9 +86,10 @@ StatusView operationStatusViewFromToken(
case 'started':
return StatusView(
label: l10n.paymentStatusProcessing,
backgroundColor: scheme.primaryContainer,
foregroundColor: scheme.onPrimaryContainer,
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
);
case 'pending':
case 'queued':
case 'waiting':
@@ -92,26 +97,29 @@ StatusView operationStatusViewFromToken(
case 'scheduled':
return StatusView(
label: l10n.operationStatusPending,
backgroundColor: scheme.secondary,
foregroundColor: scheme.onSecondary,
backgroundColor: Colors.amber,
foregroundColor: Colors.black,
);
case 'needs_attention':
return StatusView(
label: l10n.operationStepStateNeedsAttention,
backgroundColor: scheme.tertiary,
foregroundColor: scheme.onTertiary,
backgroundColor: Colors.grey.shade800,
foregroundColor: Colors.white,
);
case 'retrying':
return StatusView(
label: l10n.operationStepStateRetrying,
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
backgroundColor: Colors.purple,
foregroundColor: Colors.white,
);
default:
return StatusView(
label: fallbackLabel ?? humanizeOperationStatusToken(token),
backgroundColor: scheme.surfaceContainerHighest,
foregroundColor: scheme.onSurfaceVariant,
backgroundColor: Colors.grey.shade400,
foregroundColor: Colors.black,
);
}
}

View File

@@ -3,34 +3,35 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/localization.dart';
import 'package:pweb/utils/money_display.dart';
String formatMoney(Money? money, {String fallback = '-'}) {
if (money == null) return fallback;
final amount = money.amount.trim();
if (amount.isEmpty) return fallback;
final symbol = currencySymbolFromCode(money.currency);
final suffix = symbol ?? money.currency;
if (suffix.trim().isEmpty) return amount;
return '$amount $suffix';
String formatMoney(BuildContext context, Money? money) {
if (money == null || money.amount.trim().isEmpty) {
return unavailableMoneyValue(context);
}
return formatMoneyUi(context, money);
}
String formatAmount(double amount, String currency, {String fallback = '-'}) {
final trimmed = currency.trim();
if (trimmed.isEmpty) return amountToString(amount);
final symbol = currencySymbolFromCode(trimmed);
final suffix = symbol ?? trimmed;
return '${amountToString(amount)} $suffix';
String formatAmount(BuildContext context, double amount, String currency) {
return formatAmountUi(context, amount: amount, currency: currency);
}
String formatDateLabel(BuildContext context, DateTime? date, {String fallback = '-'}) {
String formatDateLabel(
BuildContext context,
DateTime? date, {
String fallback = '-',
}) {
if (date == null || date.millisecondsSinceEpoch == 0) return fallback;
return dateTimeToLocalFormat(context, date.toLocal());
}
String formatLongDate(BuildContext context, DateTime? date, {String fallback = '-'}) {
String formatLongDate(
BuildContext context,
DateTime? date, {
String fallback = '-',
}) {
if (date == null || date.millisecondsSinceEpoch == 0) return fallback;
final locale = Localizations.localeOf(context).toString();
final formatter = DateFormat('d MMMM y', locale);

View File

@@ -1,4 +1,4 @@
import 'package:pshared/models/payment/intent.dart';
import 'package:pshared/models/payment/endpoint.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/ledger.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
@@ -7,6 +7,13 @@ import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/source_type.dart';
typedef _SourceRefExtractor = String? Function(PaymentMethodData source);
final Map<PaymentSourceType, _SourceRefExtractor> _sourceExtractors = {
PaymentSourceType.wallet: _walletSourceRef,
PaymentSourceType.ledger: _ledgerSourceRef,
};
bool paymentMatchesSource(
Payment payment, {
required PaymentSourceType sourceType,
@@ -15,95 +22,54 @@ bool paymentMatchesSource(
final normalizedSourceRef = _normalize(sourceRef);
if (normalizedSourceRef == null) return false;
final paymentSourceRef = _paymentSourceRef(payment, sourceType);
return paymentSourceRef != null && paymentSourceRef == normalizedSourceRef;
final paymentSourceRefs = _paymentSourceRefs(payment, sourceType);
if (paymentSourceRefs.isEmpty) return false;
return paymentSourceRefs.contains(normalizedSourceRef);
}
String? _paymentSourceRef(Payment payment, PaymentSourceType sourceType) {
final fromIntent = _sourceRefFromIntent(payment.intent, sourceType);
if (fromIntent != null) return fromIntent;
return _sourceRefFromMetadata(payment.metadata, sourceType);
Set<String> _paymentSourceRefs(Payment payment, PaymentSourceType sourceType) {
final fromSource = _sourceRefsFromEndpoint(payment.source, sourceType);
if (fromSource.isEmpty) return const <String>{};
return fromSource;
}
String? _sourceRefFromIntent(
PaymentIntent? intent,
Set<String> _sourceRefsFromEndpoint(
PaymentEndpoint? endpoint,
PaymentSourceType sourceType,
) {
final source = intent?.source;
if (source == null) return null;
if (endpoint == null) return const <String>{};
final fromIntentAttributes = _sourceRefFromMetadata(
intent?.attributes,
sourceType,
);
if (fromIntentAttributes != null) return fromIntentAttributes;
switch (sourceType) {
case PaymentSourceType.wallet:
return _walletSourceRef(source);
case PaymentSourceType.ledger:
return _ledgerSourceRef(source);
final refs = <String>{};
void collect(String? value) {
final normalized = _normalize(value);
if (normalized == null) return;
refs.add(normalized);
}
final source = endpoint.method;
if (source != null) {
final fromMethod = _sourceExtractors[sourceType]?.call(source);
collect(fromMethod);
}
collect(endpoint.paymentMethodRef);
return refs;
}
String? _walletSourceRef(PaymentMethodData source) {
if (source is ManagedWalletPaymentMethod) {
return _normalize(source.managedWalletRef) ??
_sourceRefFromMetadata(source.metadata, PaymentSourceType.wallet);
}
if (source is WalletPaymentMethod) {
return _normalize(source.walletId) ??
_sourceRefFromMetadata(source.metadata, PaymentSourceType.wallet);
}
return null;
}
String? _walletSourceRef(PaymentMethodData source) => switch (source) {
ManagedWalletPaymentMethod(:final managedWalletRef) => _normalize(
managedWalletRef,
),
WalletPaymentMethod(:final walletId) => _normalize(walletId),
_ => null,
};
String? _ledgerSourceRef(PaymentMethodData source) {
if (source is LedgerPaymentMethod) {
return _normalize(source.ledgerAccountRef) ??
_sourceRefFromMetadata(source.metadata, PaymentSourceType.ledger);
}
return null;
}
String? _sourceRefFromMetadata(
Map<String, String>? metadata,
PaymentSourceType sourceType,
) {
if (metadata == null || metadata.isEmpty) return null;
final keys = switch (sourceType) {
PaymentSourceType.wallet => const <String>[
'source_wallet_ref',
'managed_wallet_ref',
'wallet_ref',
'wallet_id',
'source_wallet_id',
'source_wallet_user_id',
'wallet_user_id',
'wallet_user_ref',
'wallet_number',
'source_wallet_number',
'source_managed_wallet_ref',
'source_ref',
],
PaymentSourceType.ledger => const <String>[
'source_ledger_account_ref',
'ledger_account_ref',
'source_account_code',
'ledger_account_code',
'account_code',
'source_ref',
],
};
for (final key in keys) {
final value = _normalize(metadata[key]);
if (value != null) return value;
}
return null;
}
String? _ledgerSourceRef(PaymentMethodData source) => switch (source) {
LedgerPaymentMethod(:final ledgerAccountRef) => _normalize(ledgerAccountRef),
_ => null,
};
String? _normalize(String? value) {
final normalized = value?.trim();

View File

@@ -1,37 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/localization.dart';
import 'package:intl/intl.dart';
String formatMoney(Money? money, {String fallback = '-'}) {
final amount = money?.amount.trim();
if (amount == null || amount.isEmpty) return fallback;
return '$amount ${money!.currency}';
}
String formatAmount(double amount, String currency, {String fallback = '-'}) {
final trimmed = currency.trim();
if (trimmed.isEmpty) return amountToString(amount);
final symbol = currencySymbolFromCode(trimmed);
final suffix = symbol ?? trimmed;
return '${amountToString(amount)} $suffix';
}
String formatDateLabel(BuildContext context, DateTime? date, {String fallback = '-'}) {
if (date == null || date.millisecondsSinceEpoch == 0) return fallback;
return dateTimeToLocalFormat(context, date.toLocal());
}
String formatLongDate(BuildContext context, DateTime? date, {String fallback = '-'}) {
if (date == null || date.millisecondsSinceEpoch == 0) return fallback;
final locale = Localizations.localeOf(context).toString();
final formatter = DateFormat('d MMMM y', locale);
return formatter.format(date.toLocal());
}
String collapseWhitespace(String value) {
return value.replaceAll(RegExp(r'\s+'), ' ').trim();
}

View File

@@ -1,31 +1,31 @@
import 'package:flutter/widgets.dart';
import 'package:pshared/models/ledger/account.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pshared/utils/money.dart';
import 'package:pweb/utils/money_display.dart';
String walletBalance(Wallet wallet) {
final symbol = currencyCodeToSymbol(wallet.currency);
return '$symbol ${amountToString(wallet.balance)}';
String walletBalance(BuildContext context, Wallet wallet) {
return formatMoneyUi(
context,
Money(
amount: amountToString(wallet.balance),
currency: currencyCodeToString(wallet.currency),
),
);
}
String ledgerBalance(LedgerAccount account) {
String ledgerBalance(BuildContext context, 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 effectiveCurrency = (money?.currency.trim().isNotEmpty ?? false)
? money!.currency
: account.currency;
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;
return formatMoneyUi(
context,
Money(amount: money?.amount ?? '', currency: effectiveCurrency),
);
}

View File

@@ -11,6 +11,7 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
List<DropdownMenuItem<SourceOptionKey>> buildSourceSelectorItems({
required BuildContext context,
required List<Wallet> wallets,
required List<LedgerAccount> ledgerAccounts,
required AppLocalizations l10n,
@@ -20,7 +21,7 @@ List<DropdownMenuItem<SourceOptionKey>> buildSourceSelectorItems({
return DropdownMenuItem<SourceOptionKey>(
value: walletOptionKey(wallet.id),
child: Text(
'${walletDisplayName(wallet, l10n)} - ${walletBalance(wallet)}',
'${walletDisplayName(wallet, l10n)} - ${walletBalance(context, wallet)}',
overflow: TextOverflow.ellipsis,
),
);
@@ -29,7 +30,7 @@ List<DropdownMenuItem<SourceOptionKey>> buildSourceSelectorItems({
return DropdownMenuItem<SourceOptionKey>(
value: ledgerOptionKey(ledger.ledgerAccountRef),
child: Text(
'${ledgerDisplayName(ledger, l10n)} - ${ledgerBalance(ledger)}',
'${ledgerDisplayName(ledger, l10n)} - ${ledgerBalance(context, ledger)}',
overflow: TextOverflow.ellipsis,
),
);

View File

@@ -25,6 +25,7 @@ Widget buildSourceSelectorField({
}
final items = buildSourceSelectorItems(
context: context,
wallets: wallets,
ledgerAccounts: ledgerAccounts,
l10n: l10n,