reports page
This commit is contained in:
47
frontend/pweb/lib/pages/report/details/content.dart
Normal file
47
frontend/pweb/lib/pages/report/details/content.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
43
frontend/pweb/lib/pages/report/details/header.dart
Normal file
43
frontend/pweb/lib/pages/report/details/header.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
87
frontend/pweb/lib/pages/report/details/page.dart
Normal file
87
frontend/pweb/lib/pages/report/details/page.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
71
frontend/pweb/lib/pages/report/details/row.dart
Normal file
71
frontend/pweb/lib/pages/report/details/row.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
frontend/pweb/lib/pages/report/details/section.dart
Normal file
41
frontend/pweb/lib/pages/report/details/section.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
36
frontend/pweb/lib/pages/report/details/sections.dart
Normal file
36
frontend/pweb/lib/pages/report/details/sections.dart
Normal 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;
|
||||
|
||||
}
|
||||
70
frontend/pweb/lib/pages/report/details/sections/fx.dart
Normal file
70
frontend/pweb/lib/pages/report/details/sections/fx.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
31
frontend/pweb/lib/pages/report/details/sections/rows.dart
Normal file
31
frontend/pweb/lib/pages/report/details/sections/rows.dart
Normal 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();
|
||||
}
|
||||
34
frontend/pweb/lib/pages/report/details/states/error.dart
Normal file
34
frontend/pweb/lib/pages/report/details/states/error.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
frontend/pweb/lib/pages/report/details/states/not_found.dart
Normal file
35
frontend/pweb/lib/pages/report/details/states/not_found.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
frontend/pweb/lib/pages/report/details/summary_card/widget.dart
Normal file
124
frontend/pweb/lib/pages/report/details/summary_card/widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user