reports page

This commit is contained in:
Arseni
2026-02-16 21:05:38 +03:00
parent 11d4b9a608
commit 0eea39fb97
56 changed files with 2227 additions and 501 deletions

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/pages/report/details/header.dart';
import 'package:pweb/pages/report/details/sections.dart';
import 'package:pweb/pages/report/details/summary_card/widget.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentDetailsContent extends StatelessWidget {
final Payment payment;
final VoidCallback onBack;
final VoidCallback? onDownloadAct;
const PaymentDetailsContent({
super.key,
required this.payment,
required this.onBack,
this.onDownloadAct,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
PaymentDetailsHeader(
title: loc.paymentInfo,
onBack: onBack,
),
const SizedBox(height: 16),
PaymentSummaryCard(
payment: payment,
onDownloadAct: onDownloadAct,
),
const SizedBox(height: 16),
PaymentDetailsSections(payment: payment),
],
),
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentDetailsHeader extends StatelessWidget {
final String title;
final VoidCallback onBack;
const PaymentDetailsHeader({
super.key,
required this.title,
required this.onBack,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
IconButton(
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
tooltip: AppLocalizations.of(context)!.back,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/status.dart';
import 'package:pshared/provider/payment/payments.dart';
import 'package:pweb/app/router/payout_routes.dart';
import 'package:pweb/pages/report/details/content.dart';
import 'package:pweb/pages/report/details/states/error.dart';
import 'package:pweb/pages/report/details/states/not_found.dart';
import 'package:pweb/utils/report/download_act.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentDetailsPage extends StatelessWidget {
final String paymentId;
const PaymentDetailsPage({
super.key,
required this.paymentId,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Consumer<PaymentsProvider>(
builder: (context, provider, child) {
final loc = AppLocalizations.of(context)!;
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
return PaymentDetailsError(
message: provider.error?.toString() ?? loc.noErrorInformation,
onRetry: () => provider.refresh(),
);
}
final payment = _findPayment(provider.payments, paymentId);
if (payment == null) {
return PaymentDetailsNotFound(onBack: () => _handleBack(context));
}
final status = statusFromPayment(payment);
final paymentRef = payment.paymentRef ?? '';
final canDownload = status == OperationStatus.success &&
paymentRef.trim().isNotEmpty;
return PaymentDetailsContent(
payment: payment,
onBack: () => _handleBack(context),
onDownloadAct: canDownload
? () => downloadPaymentAct(context, paymentRef)
: null,
);
},
),
);
}
Payment? _findPayment(List<Payment> payments, String paymentId) {
final trimmed = paymentId.trim();
if (trimmed.isEmpty) return null;
for (final payment in payments) {
if (payment.paymentRef == trimmed) return payment;
if (payment.idempotencyKey == trimmed) return payment;
}
return null;
}
void _handleBack(BuildContext context) {
final router = GoRouter.of(context);
if (router.canPop()) {
context.pop();
return;
}
context.go(PayoutRoutes.reportsPath);
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
class DetailRow extends StatelessWidget {
final String label;
final String value;
final bool multiline;
final bool monospaced;
const DetailRow({
super.key,
required this.label,
required this.value,
this.multiline = false,
this.monospaced = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final valueStyle = monospaced
? theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace')
: theme.textTheme.bodyMedium;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: LayoutBuilder(
builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 250;
if (isNarrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
SelectableText(value, style: valueStyle),
],
);
}
return Row(
crossAxisAlignment: multiline
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [
Expanded(
flex: 2,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 8),
Expanded(
flex: 3,
child: SelectableText(value, style: valueStyle),
),
],
);
},
),
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
class DetailsSection extends StatelessWidget {
final String title;
final List<Widget> children;
const DetailsSection({
super.key,
required this.title,
required this.children,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor.withAlpha(25)),
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
...children,
],
),
),
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/pages/report/details/sections/fx.dart';
import 'package:pweb/pages/report/details/sections/metadata.dart';
class PaymentDetailsSections extends StatelessWidget {
final Payment payment;
const PaymentDetailsSections({
super.key,
required this.payment,
});
@override
Widget build(BuildContext context) {
final hasFx = _hasFxQuote(payment);
if (!hasFx) {
return PaymentMetadataSection(payment: payment);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: PaymentFxSection(payment: payment)),
const SizedBox(width: 16),
Expanded(child: PaymentMetadataSection(payment: payment)),
],
);
}
bool _hasFxQuote(Payment payment) => payment.lastQuote?.fxQuote != null;
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pshared/models/payment/fx/quote.dart';
import 'package:pshared/utils/currency.dart';
import 'package:pweb/pages/report/details/section.dart';
import 'package:pweb/pages/report/details/sections/rows.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentFxSection extends StatelessWidget {
final Payment 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),
),
]);
return DetailsSection(
title: loc.paymentDetailsFx,
children: rows,
);
}
String? _formatRate(FxQuote? fx) {
if (fx == null) return null;
final price = fx.price?.trim();
if (price == null || price.isEmpty) return null;
final base = _firstNonEmpty([
currencySymbolFromCode(fx.baseCurrency),
currencySymbolFromCode(fx.baseAmount?.currency),
fx.baseCurrency,
fx.baseAmount?.currency,
]);
final quote = _firstNonEmpty([
currencySymbolFromCode(fx.quoteCurrency),
currencySymbolFromCode(fx.quoteAmount?.currency),
fx.quoteCurrency,
fx.quoteAmount?.currency,
]);
if (base == null || quote == null) {
return price;
}
return '1 $base = $price $quote';
}
String? _firstNonEmpty(List<String?> values) {
for (final value in values) {
final trimmed = value?.trim();
if (trimmed != null && trimmed.isNotEmpty) return trimmed;
}
return null;
}
}

View File

@@ -0,0 +1,67 @@
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

@@ -0,0 +1,31 @@
import 'package:pweb/pages/report/details/row.dart';
class DetailValue {
final String label;
final String? value;
final bool multiline;
final bool monospaced;
const DetailValue({
required this.label,
required this.value,
this.multiline = false,
this.monospaced = false,
});
}
List<DetailRow> buildDetailRows(List<DetailValue> values) {
return values
.where((item) {
final value = item.value?.trim();
return value != null && value.isNotEmpty && value != '-';
})
.map((item) => DetailRow(
label: item.label,
value: item.value!.trim(),
multiline: item.multiline,
monospaced: item.monospaced,
))
.toList();
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentDetailsError extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const PaymentDetailsError({
super.key,
required this.message,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(loc.notificationError(message)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: onRetry,
child: Text(loc.retry),
),
],
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentDetailsNotFound extends StatelessWidget {
final VoidCallback onBack;
const PaymentDetailsNotFound({
super.key,
required this.onBack,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
loc.paymentDetailsNotFound,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
label: Text(loc.back),
),
],
),
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
class AmountHeadline extends StatelessWidget {
final String amount;
final String currency;
const AmountHeadline({
required this.amount,
required this.currency,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final amountStyle = theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
);
final currencyStyle = theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
);
if (currency.isEmpty) {
return Text(amount, style: amountStyle);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(amount, style: amountStyle),
const SizedBox(width: 4),
Text(currency, style: currencyStyle),
],
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
class CopyableId extends StatelessWidget {
final String label;
final String value;
final VoidCallback onCopy;
const CopyableId({
required this.label,
required this.value,
required this.onCopy,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final labelStyle = theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
);
final valueStyle = theme.textTheme.labelLarge?.copyWith(
fontFamily: 'monospace',
);
return InkWell(
onTap: onCopy,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withAlpha(120),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.dividerColor.withAlpha(40)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: labelStyle),
const SizedBox(width: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: valueStyle,
),
),
const SizedBox(width: 8),
Icon(
Icons.copy_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
class InfoLine extends StatelessWidget {
final IconData icon;
final String text;
final bool muted;
const InfoLine({
required this.icon,
required this.text,
this.muted = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = muted
? theme.colorScheme.onSurfaceVariant
: theme.colorScheme.onSurface;
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Icon(icon, size: 18, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Expanded(
child: Text(
text,
style: theme.textTheme.bodyMedium?.copyWith(color: color),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/payment.dart';
import 'package:pweb/pages/report/details/summary_card/amount_headline.dart';
import 'package:pweb/pages/report/details/summary_card/copy_id.dart';
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/report/payment_mapper.dart';
import 'package:pweb/utils/clipboard.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PaymentSummaryCard extends StatelessWidget {
final Payment payment;
final VoidCallback? onDownloadAct;
const PaymentSummaryCard({
super.key,
required this.payment,
this.onDownloadAct,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final status = statusFromPayment(payment);
final dateLabel = formatDateLabel(context, resolvePaymentDate(payment));
final primaryAmount = payment.lastQuote?.debitAmount ??
payment.lastQuote?.expectedSettlementAmount;
final toAmount = payment.lastQuote?.expectedSettlementAmount;
final fee = payment.lastQuote?.expectedFeeTotal ??
payment.lastQuote?.networkFee?.networkFee;
final amountLabel = formatMoney(primaryAmount);
final toAmountLabel = formatMoney(toAmount);
final feeLabel = formatMoney(fee);
final paymentRef = (payment.paymentRef ?? '').trim();
final showToAmount = toAmountLabel != '-' && toAmountLabel != amountLabel;
final showPaymentId = paymentRef.isNotEmpty;
final amountParts = splitAmount(amountLabel);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor.withAlpha(25)),
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
OperationStatusBadge(status: status),
const Spacer(),
Text(
dateLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 12),
AmountHeadline(
amount: amountParts.amount,
currency: amountParts.currency,
),
const SizedBox(height: 6),
if (amountLabel != '-')
InfoLine(
icon: Icons.send_outlined,
text: loc.sentAmount(amountLabel),
),
if (showToAmount && toAmountLabel != '-')
InfoLine(
icon: Icons.south_east,
text: loc.recipientWillReceive(toAmountLabel),
),
if (feeLabel != '-')
InfoLine(
icon: Icons.receipt_long_outlined,
text: loc.fee(feeLabel),
muted: true,
),
if (onDownloadAct != null) ...[
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onDownloadAct,
icon: const Icon(Icons.download),
label: Text(loc.downloadAct),
),
],
if (showPaymentId) ...[
const SizedBox(height: 16),
Divider(color: theme.dividerColor.withAlpha(35), height: 1),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: CopyableId(
label: loc.paymentIdLabel,
value: paymentRef,
onCopy: () => copyToClipboard(
context,
paymentRef,
loc.paymentIdCopied,
),
),
),
],
],
),
),
);
}
}