multiple payout page and small fixes

This commit is contained in:
Arseni
2026-02-11 02:48:30 +03:00
parent 66989ea36c
commit edb43f9909
77 changed files with 2120 additions and 1289 deletions

View File

@@ -6,10 +6,11 @@ import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/recipient.dart';
import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/models/dashboard_payment_mode.dart';
import 'package:pweb/pages/dashboard/buttons/balance/balance.dart';
import 'package:pweb/pages/dashboard/buttons/balance/controller.dart';
import 'package:pweb/pages/dashboard/buttons/buttons.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/title.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/widgets/title.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/widget.dart';
import 'package:pweb/pages/dashboard/payouts/single/widget.dart';
import 'package:pweb/pages/loader.dart';
@@ -42,19 +43,19 @@ class DashboardPage extends StatefulWidget {
}
class _DashboardPageState extends State<DashboardPage> {
bool _showContainerSingle = true;
bool _showContainerMultiple = false;
DashboardPayoutMode _payoutMode = DashboardPayoutMode.single;
void _setActive(bool single) {
void _setActive(DashboardPayoutMode mode) {
setState(() {
_showContainerSingle = single;
_showContainerMultiple = !single;
_payoutMode = mode;
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final showSingle = _payoutMode == DashboardPayoutMode.single;
final showMultiple = _payoutMode == DashboardPayoutMode.multiple;
return PageViewLoader(
child: SafeArea(
child: SingleChildScrollView(
@@ -66,8 +67,8 @@ class _DashboardPageState extends State<DashboardPage> {
Expanded(
flex: 0,
child: TransactionRefButton(
onTap: () => _setActive(true),
isActive: _showContainerSingle,
onTap: () => _setActive(DashboardPayoutMode.single),
isActive: showSingle,
label: l10n.sendSingle,
icon: Icons.person_add,
),
@@ -76,8 +77,8 @@ class _DashboardPageState extends State<DashboardPage> {
Expanded(
flex: 0,
child: TransactionRefButton(
onTap: () => _setActive(false),
isActive: _showContainerMultiple,
onTap: () => _setActive(DashboardPayoutMode.multiple),
isActive: showMultiple,
label: l10n.sendMultiple,
icon: Icons.group_add,
),
@@ -93,14 +94,14 @@ class _DashboardPageState extends State<DashboardPage> {
),
),
const SizedBox(height: AppSpacing.small),
if (_showContainerMultiple) TitleMultiplePayout(),
if (showMultiple) TitleMultiplePayout(),
const SizedBox(height: AppSpacing.medium),
if (_showContainerSingle)
if (showSingle)
SinglePayoutForm(
onRecipientSelected: widget.onRecipientSelected,
onGoToPayment: widget.onGoToPaymentWithoutRecipient,
),
if (_showContainerMultiple) MultiplePayoutForm(),
if (showMultiple) MultiplePayoutForm(),
],
),
),

View File

@@ -1,101 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/source_quote_panel.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/upload_panel.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadCSVSection extends StatelessWidget {
const UploadCSVSection({super.key});
static const double _verticalSpacing = 10;
static const double _iconTextSpacing = 5;
@override
Widget build(BuildContext context) {
final controller = context.watch<MultiplePayoutsController>();
final walletsController = context.watch<WalletsController>();
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.upload),
const SizedBox(width: _iconTextSpacing),
Text(
l10n.uploadCSV,
style: theme.textTheme.bodyLarge?.copyWith(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: _verticalSpacing),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: LayoutBuilder(
builder: (context, constraints) {
final useHorizontal = constraints.maxWidth >= 760;
if (!useHorizontal) {
return Column(
children: [
UploadPanel(
controller: controller,
theme: theme,
l10n: l10n,
),
const SizedBox(height: 12),
SourceQuotePanel(
controller: controller,
walletsController: walletsController,
theme: theme,
l10n: l10n,
),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 6,
child: UploadPanel(
controller: controller,
theme: theme,
l10n: l10n,
),
),
const SizedBox(width: 12),
Expanded(
flex: 5,
child: SourceQuotePanel(
controller: controller,
walletsController: walletsController,
theme: theme,
l10n: l10n,
),
),
],
);
},
),
),
],
);
}
}

View File

@@ -1,17 +0,0 @@
class MultiplePayoutRow {
final String pan;
final String firstName;
final String lastName;
final int expMonth;
final int expYear;
final String amount;
const MultiplePayoutRow({
required this.pan,
required this.firstName,
required this.lastName,
required this.expMonth,
required this.expYear,
required this.amount,
});
}

View File

@@ -1,121 +0,0 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadHistorySection extends StatelessWidget {
const UploadHistorySection({super.key});
static const double _smallBox = 5;
static const double _radius = 6;
@override
Widget build(BuildContext context) {
final provider = context.watch<PaymentsProvider>();
final theme = Theme.of(context);
final l10 = AppLocalizations.of(context)!;
final dateFormat = DateFormat.yMMMd().add_Hm();
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
return Text(
l10.notificationError(provider.error ?? l10.noErrorInformation),
);
}
final items = List.of(provider.payments);
items.sort((a, b) {
final left = a.createdAt;
final right = b.createdAt;
if (left == null && right == null) return 0;
if (left == null) return 1;
if (right == null) return -1;
return right.compareTo(left);
});
return Column(
children: [
Row(
children: [
const Icon(Icons.history),
const SizedBox(width: _smallBox),
Text(l10.uploadHistory, style: theme.textTheme.bodyLarge),
],
),
const SizedBox(height: 8),
if (items.isEmpty)
Align(
alignment: Alignment.centerLeft,
child: Text(
l10.walletHistoryEmpty,
style: theme.textTheme.bodyMedium,
),
)
else
DataTable(
columns: [
DataColumn(label: Text(l10.fileNameColumn)),
DataColumn(label: Text(l10.rowsColumn)),
DataColumn(label: Text(l10.dateColumn)),
DataColumn(label: Text(l10.amountColumn)),
DataColumn(label: Text(l10.statusColumn)),
],
rows: items.map((payment) {
final metadata = payment.metadata;
final state = payment.state ?? '-';
final statusColor =
payment.isFailure ? Colors.red : Colors.green;
final fileName = metadata?['upload_filename'];
final fileNameText =
(fileName == null || fileName.isEmpty) ? '-' : fileName;
final rows = metadata?['upload_rows'];
final rowsText = (rows == null || rows.isEmpty) ? '-' : rows;
final createdAt = payment.createdAt;
final dateText = createdAt == null
? '-'
: dateFormat.format(createdAt.toLocal());
final amountValue = metadata?['upload_amount'];
final amountCurrency = metadata?['upload_currency'];
final fallbackAmount = payment.lastQuote?.debitAmount;
final amountText = (amountValue == null || amountValue.isEmpty)
? (fallbackAmount == null
? '-'
: '${fallbackAmount.amount} ${fallbackAmount.currency}')
: (amountCurrency == null || amountCurrency.isEmpty
? amountValue
: '$amountValue $amountCurrency');
return DataRow(
cells: [
DataCell(Text(fileNameText)),
DataCell(Text(rowsText)),
DataCell(Text(dateText)),
DataCell(Text(amountText)),
DataCell(
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: statusColor.withAlpha(20),
borderRadius: BorderRadius.circular(_radius),
),
child: Text(state, style: TextStyle(color: statusColor)),
),
),
],
);
}).toList(),
),
],
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SourceQuotePanelHeader extends StatelessWidget {
const SourceQuotePanelHeader({
super.key,
required this.theme,
required this.l10n,
});
final ThemeData theme;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
return Text(
l10n.sourceOfFunds,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:pshared/models/asset.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
String moneyLabel(Money? money) {
if (money == null) return 'N/A';
final amount = double.tryParse(money.amount);
if (amount == null) 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) {
final feeLabelText = moneyLabel(controller.aggregateFeeAmount);
final percent = controller.aggregateFeePercent;
if (percent == null) return feeLabelText;
return '$feeLabelText (${percent.toStringAsFixed(2)}%)';
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SourceWalletSelector extends StatelessWidget {
const SourceWalletSelector({
super.key,
required this.controller,
required this.walletsController,
required this.theme,
required this.l10n,
});
final MultiplePayoutsController controller;
final WalletsController walletsController;
final ThemeData theme;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
final wallets = walletsController.wallets;
final selectedWalletRef = walletsController.selectedWalletRef;
if (wallets.isEmpty) {
return Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall);
}
return DropdownButtonFormField<String>(
initialValue: selectedWalletRef,
isExpanded: true,
decoration: InputDecoration(
labelText: l10n.whereGetMoney,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
items: wallets
.map(
(wallet) => DropdownMenuItem<String>(
value: wallet.id,
child: Text(
'${wallet.name} - ${amountToString(wallet.balance)} ${currencyCodeToString(wallet.currency)}',
overflow: TextOverflow.ellipsis,
),
),
)
.toList(growable: false),
onChanged: controller.isBusy
? null
: (value) {
if (value == null) return;
walletsController.selectWalletByRef(value);
},
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/models/summary_values.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/source_quote/helpers.dart';
import 'package:pweb/pages/dashboard/payouts/summary/widget.dart';
class SourceQuoteSummary extends StatelessWidget {
const SourceQuoteSummary({
super.key,
required this.controller,
required this.spacing,
});
final MultiplePayoutsController controller;
final double spacing;
@override
Widget build(BuildContext context) {
return PaymentSummary(
spacing: spacing,
values: PaymentSummaryValues(
sentAmount: sentAmountLabel(controller),
fee: feeLabel(controller),
recipientReceives: moneyLabel(controller.aggregateSettlementAmount),
total: moneyLabel(controller.aggregateDebitAmount),
),
);
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pweb/controllers/multiple_payouts.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/panels/source_quote/selector.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/widgets/quote_status.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SourceQuotePanel extends StatelessWidget {
const SourceQuotePanel({
super.key,
required this.controller,
required this.walletsController,
required this.theme,
required this.l10n,
});
final MultiplePayoutsController controller;
final WalletsController walletsController;
final ThemeData theme;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
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(theme: theme, l10n: l10n),
const SizedBox(height: 8),
SourceWalletSelector(
controller: controller,
walletsController: walletsController,
theme: theme,
l10n: l10n,
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
SourceQuoteSummary(controller: controller, spacing: 12),
const SizedBox(height: 12),
MultipleQuoteStatusCard(controller: controller),
],
),
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadPanelActions extends StatelessWidget {
const UploadPanelActions({
super.key,
required this.controller,
required this.l10n,
required this.onSend,
});
final MultiplePayoutsController controller;
final AppLocalizations l10n;
final VoidCallback onSend;
static const double _buttonVerticalPadding = 12;
static const double _buttonHorizontalPadding = 24;
@override
Widget build(BuildContext context) {
final hasFile = controller.selectedFileName != null;
return Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
OutlinedButton(
onPressed: !hasFile || controller.isBusy
? null
: () => controller.removeUploadedFile(),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: _buttonHorizontalPadding,
vertical: _buttonVerticalPadding,
),
),
child: Text(l10n.cancel),
),
ElevatedButton(
onPressed: controller.canSend ? onSend : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: _buttonHorizontalPadding,
vertical: _buttonVerticalPadding,
),
),
child: Text(l10n.send),
),
],
);
}
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadDropZone extends StatelessWidget {
const UploadDropZone({
super.key,
required this.controller,
required this.theme,
required this.l10n,
});
final MultiplePayoutsController controller;
final ThemeData theme;
final AppLocalizations l10n;
static const double _panelRadius = 12;
@override
Widget build(BuildContext context) {
final hasFile = controller.selectedFileName != null;
return InkWell(
onTap: controller.isBusy
? null
: () => controller.pickAndQuote(),
borderRadius: BorderRadius.circular(_panelRadius),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
border: Border.all(color: theme.colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(_panelRadius),
),
child: Column(
children: [
Icon(
Icons.upload_file,
size: 34,
color: theme.colorScheme.primary,
),
const SizedBox(height: 8),
Text(
hasFile ? controller.selectedFileName! : l10n.uploadCSV,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
if (!hasFile) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.12),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.5),
),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.touch_app,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: 6),
Text(
l10n.upload,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
const SizedBox(height: 6),
Text(
l10n.hintUpload,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
if (hasFile) ...[
const SizedBox(height: 6),
Text(
'${l10n.payout}: ${controller.rows.length}',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/widgets/dialogs/payment_status_dialog.dart';
Future<void> handleUploadSend(
BuildContext context,
MultiplePayoutsController controller,
) async {
final outcome = await controller.sendAndStorePayments();
if (!context.mounted) return;
await showPaymentStatusDialog(
context,
isSuccess: outcome == MultiplePayoutSendOutcome.success,
);
controller.removeUploadedFile();
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
class UploadQuoteProgress extends StatelessWidget {
const UploadQuoteProgress({
super.key,
required this.isQuoting,
required this.theme,
});
final bool isQuoting;
final ThemeData theme;
@override
Widget build(BuildContext context) {
if (!isQuoting) return const SizedBox.shrink();
return Column(
children: [
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 5,
color: theme.colorScheme.primary,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
),
),
],
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadPanelStatus extends StatelessWidget {
const UploadPanelStatus({
super.key,
required this.controller,
required this.theme,
required this.l10n,
});
final MultiplePayoutsController controller;
final ThemeData theme;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
if (controller.sentCount <= 0 && controller.error == null) {
return const SizedBox.shrink();
}
return Column(
children: [
if (controller.sentCount > 0) ...[
const SizedBox(height: 8),
Text(
'${l10n.payout}: ${controller.sentCount}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
),
),
],
if (controller.error != null) ...[
const SizedBox(height: 8),
Text(
controller.error.toString(),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/drop_zone.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/actions.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/helpers.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/status.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/panels/upload_panel/progress.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadPanel extends StatelessWidget {
const UploadPanel({
super.key,
required this.controller,
required this.theme,
required this.l10n,
});
final MultiplePayoutsController controller;
final ThemeData theme;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
UploadDropZone(controller: controller, theme: theme, l10n: l10n),
UploadQuoteProgress(isQuoting: controller.isQuoting, theme: theme),
const SizedBox(height: 12),
UploadPanelActions(
controller: controller,
l10n: l10n,
onSend: () => handleUploadSend(context, controller),
),
UploadPanelStatus(controller: controller, theme: theme, l10n: l10n),
],
);
}
}

View File

@@ -1,125 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:pshared/models/file/downloaded_file.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/form.dart';
import 'package:pweb/utils/download.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class FileFormatSampleSection extends StatelessWidget {
const FileFormatSampleSection({super.key});
static final List<MultiplePayoutRow> sampleRows = [
MultiplePayoutRow(
pan: "9022****11",
firstName: "Alex",
lastName: "Ivanov",
expMonth: 12,
expYear: 27,
amount: "500",
),
MultiplePayoutRow(
pan: "9022****12",
firstName: "Maria",
lastName: "Sokolova",
expMonth: 7,
expYear: 26,
amount: "100",
),
MultiplePayoutRow(
pan: "9022****13",
firstName: "Dmitry",
lastName: "Smirnov",
expMonth: 3,
expYear: 28,
amount: "120",
),
];
static const String _sampleFileName = 'sample.csv';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
final titleStyle = theme.textTheme.bodyLarge?.copyWith(
fontSize: 18,
fontWeight: FontWeight.w600,
);
final linkStyle = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.primary,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.filter_list),
const SizedBox(width: 5),
Text(l10n.exampleTitle, style: titleStyle),
],
),
const SizedBox(height: 12),
_buildDataTable(l10n),
const SizedBox(height: 10),
TextButton(
onPressed: _downloadSampleCsv,
style: TextButton.styleFrom(padding: EdgeInsets.zero),
child: Text(l10n.downloadSampleCSV, style: linkStyle),
),
],
);
}
Widget _buildDataTable(AppLocalizations l10n) {
return DataTable(
columnSpacing: 20,
columns: [
DataColumn(label: Text(l10n.cardNumberColumn)),
DataColumn(label: Text(l10n.firstName)),
DataColumn(label: Text(l10n.lastName)),
DataColumn(label: Text(l10n.expiryDate)),
DataColumn(label: Text(l10n.amount)),
],
rows: sampleRows.map((row) {
return DataRow(
cells: [
DataCell(Text(row.pan)),
DataCell(Text(row.firstName)),
DataCell(Text(row.lastName)),
DataCell(
Text('${row.expMonth.toString().padLeft(2, '0')}/${row.expYear}'),
),
DataCell(Text(row.amount)),
],
);
}).toList(),
);
}
Future<void> _downloadSampleCsv() async {
final rows = <String>[
'pan,first_name,last_name,exp_month,exp_year,amount',
...sampleRows.map(
(row) =>
'${row.pan},${row.firstName},${row.lastName},${row.expMonth},${row.expYear},${row.amount}',
),
];
final content = rows.join('\n');
await downloadFile(
DownloadedFile(
bytes: utf8.encode(content),
filename: _sampleFileName,
mimeType: 'text/csv;charset=utf-8',
),
);
}
}

View File

@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadHistoryHeader extends StatelessWidget {
const UploadHistoryHeader({
super.key,
required this.theme,
required this.l10n,
});
final ThemeData theme;
final AppLocalizations l10n;
static const double _smallBox = 5;
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.history),
const SizedBox(width: _smallBox),
Text(l10n.uploadHistory, style: theme.textTheme.bodyLarge),
],
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class StatusView {
final String label;
final Color color;
const StatusView(this.label, this.color);
}
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.yellow);
case 'SUCCESS':
return StatusView(l10n.paymentStatusSuccessful, Colors.green);
case 'FUNDS_RESERVED':
return StatusView(l10n.paymentStatusReserved, Colors.blue);
case 'ACCEPTED':
return StatusView(l10n.paymentStatusProcessing, Colors.yellow);
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);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/helpers.dart';
class HistoryStatusBadge extends StatelessWidget {
const HistoryStatusBadge({
super.key,
required this.statusView,
});
final StatusView statusView;
static const double _radius = 6;
static const double _statusBgOpacity = 0.12;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: statusView.color.withValues(alpha: _statusBgOpacity),
borderRadius: BorderRadius.circular(_radius),
),
child: Text(
statusView.label,
style: TextStyle(
color: statusView.color,
fontWeight: FontWeight.w600,
),
),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/controllers/upload_history_table.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/helpers.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/status_badge.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadHistoryTable extends StatelessWidget {
const UploadHistoryTable({
super.key,
required this.items,
required this.dateFormat,
required this.l10n,
});
final List<Payment> items;
final DateFormat dateFormat;
final AppLocalizations l10n;
static const int _maxVisibleItems = 10;
static const UploadHistoryTableController _controller =
UploadHistoryTableController();
@override
Widget build(BuildContext context) {
final visibleItems = items.take(_maxVisibleItems).toList(growable: false);
return DataTable(
columns: [
DataColumn(label: Text(l10n.fileNameColumn)),
DataColumn(label: Text(l10n.rowsColumn)),
DataColumn(label: Text(l10n.dateColumn)),
DataColumn(label: Text(l10n.amountColumn)),
DataColumn(label: Text(l10n.statusColumn)),
],
rows: visibleItems.map((payment) {
final metadata = payment.metadata;
final status = statusView(l10n, payment.state);
final fileName = metadata?['upload_filename'];
final fileNameText =
(fileName == null || fileName.isEmpty) ? '-' : fileName;
final rows = metadata?['upload_rows'];
final rowsText = (rows == null || rows.isEmpty) ? '-' : rows;
final createdAt = payment.createdAt;
final dateText = createdAt == null
? '-'
: dateFormat.format(createdAt.toLocal());
final amountText = _controller.amountText(payment);
return DataRow(
cells: [
DataCell(Text(fileNameText)),
DataCell(Text(rowsText)),
DataCell(Text(dateText)),
DataCell(Text(amountText)),
DataCell(HistoryStatusBadge(statusView: status)),
],
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/header.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/table.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadHistorySection extends StatelessWidget {
const UploadHistorySection({super.key});
@override
Widget build(BuildContext context) {
final provider = context.watch<PaymentsProvider>();
final theme = Theme.of(context);
final l10 = AppLocalizations.of(context)!;
final dateFormat = DateFormat.yMMMd().add_Hm();
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
return Text(
l10.notificationError(provider.error ?? l10.noErrorInformation),
);
}
final items = List.of(provider.payments);
items.sort((a, b) {
final left = a.createdAt;
final right = b.createdAt;
if (left == null && right == null) return 0;
if (left == null) return 1;
if (right == null) return -1;
return right.compareTo(left);
});
return Column(
children: [
UploadHistoryHeader(theme: theme, l10n: l10),
const SizedBox(height: 8),
if (items.isEmpty)
Align(
alignment: Alignment.centerLeft,
child: Text(
l10.walletHistoryEmpty,
style: theme.textTheme.bodyMedium,
),
)
else ...[
UploadHistoryTable(
items: items,
dateFormat: dateFormat,
l10n: l10,
),
//TODO redirect to Reports page
// if (hasMore) ...[
// const SizedBox(height: 8),
// Align(
// alignment: Alignment.centerLeft,
// child: TextButton.icon(
// onPressed: () => context.goNamed(PayoutRoutes.reports),
// icon: const Icon(Icons.open_in_new, size: 16),
// label: Text(l10.viewWholeHistory),
// ),
// ),
// ],
],
],
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:pweb/models/multiple_payouts/csv_row.dart';
const String sampleFileName = 'sample.csv';
final List<CsvPayoutRow> sampleRows = [
CsvPayoutRow(
pan: "9022****11",
firstName: "Alex",
lastName: "Ivanov",
expMonth: 12,
expYear: 27,
amount: "500",
),
CsvPayoutRow(
pan: "9022****12",
firstName: "Maria",
lastName: "Sokolova",
expMonth: 7,
expYear: 26,
amount: "100",
),
CsvPayoutRow(
pan: "9022****13",
firstName: "Dmitry",
lastName: "Smirnov",
expMonth: 3,
expYear: 28,
amount: "120",
),
];
String buildSampleCsvContent() {
final rows = <String>[
'pan,first_name,last_name,exp_month,exp_year,amount',
...sampleRows.map(
(row) =>
'${row.pan},${row.firstName},${row.lastName},${row.expMonth},${row.expYear},${row.amount}',
),
];
return rows.join('\n');
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class FileFormatSampleDownloadButton extends StatelessWidget {
const FileFormatSampleDownloadButton({
super.key,
required this.theme,
required this.l10n,
required this.onPressed,
});
final ThemeData theme;
final AppLocalizations l10n;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final linkStyle = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.primary,
);
return TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(padding: EdgeInsets.zero),
child: Text(l10n.downloadSampleCSV, style: linkStyle),
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class FileFormatSampleHeader extends StatelessWidget {
const FileFormatSampleHeader({
super.key,
required this.theme,
required this.l10n,
});
final ThemeData theme;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
final titleStyle = theme.textTheme.bodyLarge?.copyWith(
fontSize: 18,
fontWeight: FontWeight.w600,
);
return Row(
children: [
const Icon(Icons.filter_list),
const SizedBox(width: 5),
Text(l10n.exampleTitle, style: titleStyle),
],
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:pweb/models/multiple_payouts/csv_row.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class FileFormatSampleTable extends StatelessWidget {
const FileFormatSampleTable({
super.key,
required this.l10n,
required this.rows,
});
final AppLocalizations l10n;
final List<CsvPayoutRow> rows;
@override
Widget build(BuildContext context) {
return DataTable(
columnSpacing: 20,
columns: [
DataColumn(label: Text(l10n.cardNumberColumn)),
DataColumn(label: Text(l10n.firstName)),
DataColumn(label: Text(l10n.lastName)),
DataColumn(label: Text(l10n.expiryDate)),
DataColumn(label: Text(l10n.amount)),
],
rows: rows.map((row) {
return DataRow(
cells: [
DataCell(Text(row.pan)),
DataCell(Text(row.firstName)),
DataCell(Text(row.lastName)),
DataCell(
Text('${row.expMonth.toString().padLeft(2, '0')}/${row.expYear}'),
),
DataCell(Text(row.amount)),
],
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,48 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:pshared/models/file/downloaded_file.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/sample/data.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/sample/download_button.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/sample/header.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/sample/table.dart';
import 'package:pweb/utils/download.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class FileFormatSampleSection extends StatelessWidget {
const FileFormatSampleSection({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FileFormatSampleHeader(theme: theme, l10n: l10n),
const SizedBox(height: 12),
FileFormatSampleTable(l10n: l10n, rows: sampleRows),
const SizedBox(height: 10),
FileFormatSampleDownloadButton(
theme: theme,
l10n: l10n,
onPressed: _downloadSampleCsv,
),
],
);
}
Future<void> _downloadSampleCsv() async {
await downloadFile(
DownloadedFile(
bytes: utf8.encode(buildSampleCsvContent()),
filename: sampleFileName,
mimeType: 'text/csv;charset=utf-8',
),
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadCsvHeader extends StatelessWidget {
const UploadCsvHeader({
super.key,
required this.theme,
required this.l10n,
});
final ThemeData theme;
final AppLocalizations l10n;
static const double _iconTextSpacing = 5;
@override
Widget build(BuildContext context) {
return Row(
children: [
const Icon(Icons.upload),
const SizedBox(width: _iconTextSpacing),
Text(
l10n.uploadCSV,
style: theme.textTheme.bodyLarge?.copyWith(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
],
);
}
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pweb/controllers/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';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/panel_card.dart';
class UploadCsvLayout extends StatelessWidget {
const UploadCsvLayout({
super.key,
required this.controller,
required this.walletsController,
required this.theme,
required this.l10n,
});
final MultiplePayoutsController controller;
final WalletsController walletsController;
final ThemeData theme;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final useHorizontal = constraints.maxWidth >= 760;
if (!useHorizontal) {
return Column(
children: [
PanelCard(
theme: theme,
child: UploadPanel(
controller: controller,
theme: theme,
l10n: l10n,
),
),
const SizedBox(height: 12),
SourceQuotePanel(
controller: controller,
walletsController: walletsController,
theme: theme,
l10n: l10n,
),
],
);
}
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 3,
child: PanelCard(
theme: theme,
child: UploadPanel(
controller: controller,
theme: theme,
l10n: l10n,
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 5,
child: SourceQuotePanel(
controller: controller,
walletsController: walletsController,
theme: theme,
l10n: l10n,
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
class PanelCard extends StatelessWidget {
const PanelCard({
super.key,
required this.theme,
required this.child,
});
final ThemeData theme;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border.all(color: theme.colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(8),
),
child: child,
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/controllers/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';
import 'package:pweb/generated/i18n/app_localizations.dart';
class UploadCSVSection extends StatelessWidget {
const UploadCSVSection({super.key});
static const double _verticalSpacing = 10;
@override
Widget build(BuildContext context) {
final controller = context.watch<MultiplePayoutsController>();
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UploadCsvHeader(theme: theme, l10n: l10n),
const SizedBox(height: _verticalSpacing),
UploadCsvLayout(
controller: controller,
walletsController: context.watch(),
theme: theme,
l10n: l10n,
),
],
);
}
}

View File

@@ -1,134 +0,0 @@
import 'package:flutter/material.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/models/money.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SourceQuotePanel extends StatelessWidget {
const SourceQuotePanel({
super.key,
required this.controller,
required this.walletsController,
required this.theme,
required this.l10n,
});
final MultiplePayoutsController controller;
final WalletsController walletsController;
final ThemeData theme;
final AppLocalizations l10n;
@override
Widget build(BuildContext context) {
final wallets = walletsController.wallets;
final selectedWalletRef = walletsController.selectedWalletRef;
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: [
Text(
l10n.sourceOfFunds,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
if (wallets.isEmpty)
Text(l10n.noWalletsAvailable, style: theme.textTheme.bodySmall)
else
DropdownButtonFormField<String>(
initialValue: selectedWalletRef,
isExpanded: true,
decoration: InputDecoration(
labelText: l10n.whereGetMoney,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
items: wallets
.map(
(wallet) => DropdownMenuItem<String>(
value: wallet.id,
child: Text(
'${wallet.name} · ${amountToString(wallet.balance)} ${currencyCodeToString(wallet.currency)}',
overflow: TextOverflow.ellipsis,
),
),
)
.toList(growable: false),
onChanged: controller.isBusy
? null
: (value) {
if (value == null) return;
walletsController.selectWalletByRef(value);
},
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
Text(
controller.aggregateDebitAmount == null
? l10n.quoteUnavailable
: l10n.quoteActive,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
l10n.sentAmount(_sentAmountLabel(controller)),
style: theme.textTheme.bodyMedium,
),
Text(
l10n.recipientsWillReceive(
_moneyLabel(controller.aggregateSettlementAmount),
),
style: theme.textTheme.bodyMedium,
),
Text(
controller.aggregateFeePercent == null
? l10n.fee(_moneyLabel(controller.aggregateFeeAmount))
: '${l10n.fee(_moneyLabel(controller.aggregateFeeAmount))} (${controller.aggregateFeePercent!.toStringAsFixed(2)}%)',
style: theme.textTheme.bodyMedium,
),
],
),
);
}
String _moneyLabel(Money? money) {
if (money == null) return '-';
return '${money.amount} ${money.currency}';
}
String _sentAmountLabel(MultiplePayoutsController controller) {
final requested = controller.requestedSentAmount;
final sourceDebit = controller.aggregateDebitAmount;
if (requested == null && sourceDebit == null) return '-';
if (requested == null) return _moneyLabel(sourceDebit);
if (sourceDebit == null) return _moneyLabel(requested);
if (requested.currency.toUpperCase() ==
sourceDebit.currency.toUpperCase()) {
return _moneyLabel(sourceDebit);
}
return '${_moneyLabel(requested)} (${_moneyLabel(sourceDebit)})';
}
}

View File

@@ -1,160 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
//TODO this file is too long
class UploadPanel extends StatelessWidget {
const UploadPanel({
super.key,
required this.controller,
required this.theme,
required this.l10n,
});
final MultiplePayoutsController controller;
final ThemeData theme;
final AppLocalizations l10n;
static const double _buttonVerticalPadding = 12;
static const double _buttonHorizontalPadding = 24;
@override
Widget build(BuildContext context) {
return Column(
children: [
Icon(Icons.upload_file, size: 36, color: theme.colorScheme.primary),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
ElevatedButton(
onPressed: controller.isBusy
? null
: () => context
.read<MultiplePayoutsController>()
.pickAndQuote(),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: _buttonHorizontalPadding,
vertical: _buttonVerticalPadding,
),
),
child: Text(l10n.upload),
),
ElevatedButton(
onPressed: controller.canSend
? () => _handleSend(context)
: null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: _buttonHorizontalPadding,
vertical: _buttonVerticalPadding,
),
),
child: Text(l10n.send),
),
],
),
const SizedBox(height: 8),
Text(
l10n.hintUpload,
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.center,
),
if (controller.isQuoting || controller.isSending) ...[
const SizedBox(height: 10),
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
if (controller.selectedFileName != null) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
'${controller.selectedFileName} · ${controller.rows.length}',
style: theme.textTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
tooltip: l10n.close,
visualDensity: VisualDensity.compact,
onPressed: controller.isBusy
? null
: () => context
.read<MultiplePayoutsController>()
.removeUploadedFile(),
icon: const Icon(Icons.close, size: 18),
),
],
),
],
if (controller.sentCount > 0) ...[
const SizedBox(height: 8),
Text(
'${l10n.payout}: ${controller.sentCount}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
),
),
],
if (controller.error != null) ...[
const SizedBox(height: 8),
Text(
controller.error.toString(),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
);
}
Future<void> _handleSend(BuildContext context) async {
final paymentsProvider = context.read<PaymentsProvider>();
final result = await controller.send();
paymentsProvider.addPayments(result);
await paymentsProvider.refresh();
if (!context.mounted) return;
final isSuccess = controller.error == null && result.isNotEmpty;
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(
isSuccess
? l10n.paymentStatusSuccessTitle
: l10n.paymentStatusFailureTitle,
),
content: Text(
isSuccess
? l10n.paymentStatusSuccessMessage
: l10n.paymentStatusFailureMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.close),
),
],
),
);
controller.removeUploadedFile();
}
}

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/csv.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/history.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sample.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/upload_csv/widget.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/history/widget.dart';
import 'package:pweb/pages/dashboard/payouts/multiple/sections/sample/widget.dart';
class MultiplePayoutForm extends StatelessWidget {

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/quote/status_type.dart';
import 'package:pweb/controllers/multiple_payouts.dart';
import 'package:pweb/pages/dashboard/payouts/quote_status/widgets/card.dart';
import 'package:pweb/utils/quote_duration_format.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class MultipleQuoteStatusCard extends StatelessWidget {
const MultipleQuoteStatusCard({
super.key,
required this.controller,
});
final MultiplePayoutsController controller;
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final isLoading = controller.quoteIsLoading;
final statusType = controller.quoteStatusType;
final timeLeft = controller.quoteTimeLeft;
String statusText;
String? helperText;
switch (statusType) {
case QuoteStatusType.loading:
statusText = loc.quoteUpdating;
break;
case QuoteStatusType.error:
statusText = loc.quoteErrorGeneric;
break;
case QuoteStatusType.missing:
statusText = loc.quoteUnavailable;
break;
case QuoteStatusType.expired:
statusText = loc.quoteExpired;
break;
case QuoteStatusType.active:
statusText = timeLeft == null
? loc.quoteActive
: loc.quoteExpiresIn(formatQuoteDuration(timeLeft));
break;
}
return QuoteStatusCard(
statusType: statusType,
statusText: statusText,
helperText: helperText,
isLoading: isLoading,
canRefresh: false,
showPrimaryRefresh: false,
onRefresh: () {},
);
}
}

View File

@@ -7,18 +7,21 @@ import 'package:pshared/utils/currency.dart';
class PaymentSummaryRow extends StatelessWidget {
final String Function(String) labelFactory;
final Asset? asset;
final String? value;
final TextStyle? style;
const PaymentSummaryRow({
super.key,
required this.labelFactory,
required this.asset,
this.value,
this.style,
});
@override
Widget build(BuildContext context) => Text(
labelFactory(asset == null ? 'N/A' : assetToString(asset!)),
style: style,
);
Widget build(BuildContext context) {
final formatted = value ??
(asset == null ? 'N/A' : assetToString(asset!));
return Text(labelFactory(formatted), style: style);
}
}

View File

@@ -5,29 +5,86 @@ import 'package:provider/provider.dart';
import 'package:pshared/controllers/balance_mask/wallets.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/models/summary_values.dart';
import 'package:pweb/pages/dashboard/payouts/summary/fee.dart';
import 'package:pweb/pages/dashboard/payouts/summary/recipient_receives.dart';
import 'package:pweb/pages/dashboard/payouts/summary/row.dart';
import 'package:pweb/pages/dashboard/payouts/summary/sent_amount.dart';
import 'package:pweb/pages/dashboard/payouts/summary/total.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentSummary extends StatelessWidget {
final double spacing;
final PaymentSummaryValues? values;
const PaymentSummary({super.key, required this.spacing});
const PaymentSummary({
super.key,
required this.spacing,
this.values,
});
@override
Widget build(BuildContext context) => Align(
alignment: Alignment.center,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PaymentSentAmountRow(currency: currencyStringToCode(context.read<WalletsController>().selectedWallet?.tokenSymbol ?? 'USDT')),
const PaymentFeeRow(),
const PaymentRecipientReceivesRow(),
SizedBox(height: spacing),
const PaymentTotalRow(),
],
),
);
}
Widget build(BuildContext context) {
final resolvedValues = values;
if (resolvedValues != null) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!;
return Align(
alignment: Alignment.center,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PaymentSummaryRow(
labelFactory: loc.sentAmount,
asset: null,
value: resolvedValues.sentAmount,
style: theme.textTheme.titleMedium,
),
PaymentSummaryRow(
labelFactory: loc.fee,
asset: null,
value: resolvedValues.fee,
style: theme.textTheme.titleMedium,
),
PaymentSummaryRow(
labelFactory: loc.recipientWillReceive,
asset: null,
value: resolvedValues.recipientReceives,
style: theme.textTheme.titleMedium,
),
SizedBox(height: spacing),
PaymentSummaryRow(
labelFactory: loc.total,
asset: null,
value: resolvedValues.total,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
);
}
return Align(
alignment: Alignment.center,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PaymentSentAmountRow(
currency: currencyStringToCode(
context.read<WalletsController>().selectedWallet?.tokenSymbol ??
'USDT',
),
),
const PaymentFeeRow(),
const PaymentRecipientReceivesRow(),
SizedBox(height: spacing),
const PaymentTotalRow(),
],
),
);
}
}