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,43 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/pages/report/cards/items.dart';
class OperationsCardsColumn extends StatelessWidget {
final List<OperationItem> operations;
final ValueChanged<OperationItem>? onTap;
const OperationsCardsColumn({
super.key,
required this.operations,
this.onTap,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final items = buildOperationCardItems(
context,
operations,
onTap: onTap,
);
if (operations.isEmpty) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
loc.reportPaymentsEmpty,
style: theme.textTheme.bodyMedium,
),
);
}
return Column(
children: items,
);
}
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pweb/pages/report/cards/operation_card.dart';
import 'package:pweb/utils/report/format.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
List<Widget> buildOperationCardItems(
BuildContext context,
List<OperationItem> operations, {
ValueChanged<OperationItem>? onTap,
}) {
final loc = AppLocalizations.of(context)!;
final items = <Widget>[];
String? currentKey;
for (final operation in operations) {
final dateKey = _dateKey(operation.date);
if (dateKey != currentKey) {
if (items.isNotEmpty) {
items.add(const SizedBox(height: 16));
}
items.add(_DateHeader(
label: _dateLabel(context, operation.date, loc),
));
items.add(const SizedBox(height: 8));
currentKey = dateKey;
}
items.add(OperationCard(
operation: operation,
onTap: onTap,
));
items.add(const SizedBox(height: 12));
}
if (items.isNotEmpty) {
items.removeLast();
}
return items;
}
String _dateKey(DateTime date) {
if (date.millisecondsSinceEpoch == 0) return 'unknown';
final local = date.toLocal();
final normalized = DateTime(local.year, local.month, local.day);
return normalized.toIso8601String();
}
String _dateLabel(BuildContext context, DateTime date, AppLocalizations loc) {
if (date.millisecondsSinceEpoch == 0) return loc.unknown;
return formatLongDate(context, date);
}
class _DateHeader extends StatelessWidget {
final String label;
const _DateHeader({required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pweb/pages/report/cards/items.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationsCardsList extends StatelessWidget {
final List<OperationItem> operations;
final ValueChanged<OperationItem>? onTap;
const OperationsCardsList({
super.key,
required this.operations,
this.onTap,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final items = buildOperationCardItems(
context,
operations,
onTap: onTap,
);
return Expanded(
child: operations.isEmpty
? Center(
child: Text(
loc.reportPaymentsEmpty,
style: Theme.of(context).textTheme.bodyMedium,
),
)
: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => items[index],
),
);
}
}

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart';
import 'package:pweb/pages/report/cards/operation_card_utils.dart';
import 'package:pweb/pages/report/cards/operation_info_row.dart';
import 'package:pweb/pages/report/table/badge.dart';
import 'package:pweb/utils/report/format.dart';
import 'package:pweb/utils/report/payment_mapper.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class OperationCard extends StatelessWidget {
final OperationItem operation;
final ValueChanged<OperationItem>? onTap;
const OperationCard({
super.key,
required this.operation,
this.onTap,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final canOpen = onTap != null && paymentIdFromOperation(operation) != null;
final amountLabel = formatAmount(operation.amount, operation.currency);
final toAmountLabel = formatAmount(operation.toAmount, operation.toCurrency);
final showToAmount = shouldShowToAmount(operation);
final timeLabel = formatOperationTime(context, operation.date);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor.withAlpha(25)),
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: canOpen ? () => onTap?.call(operation) : null,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
OperationStatusBadge(status: operation.status),
const Spacer(),
Text(
timeLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 12),
Text(
amountLabel,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (showToAmount)
Text(
loc.recipientWillReceive(toAmountLabel),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if ((operation.fileName ?? '').trim().isNotEmpty) ...[
const SizedBox(height: 6),
OperationInfoRow(
icon: Icons.description,
value: operation.fileName!,
),
],
],
),
),
),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/payment/operation.dart';
bool shouldShowToAmount(OperationItem operation) {
if (operation.toCurrency.trim().isEmpty) return false;
if (operation.currency.trim().isEmpty) return true;
if (operation.currency != operation.toCurrency) return true;
return (operation.toAmount - operation.amount).abs() > 0.0001;
}
String formatOperationTime(BuildContext context, DateTime date) {
if (date.millisecondsSinceEpoch == 0) return '-';
return TimeOfDay.fromDateTime(date.toLocal()).format(context);
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class OperationInfoRow extends StatelessWidget {
final IconData icon;
final String value;
const OperationInfoRow({
super.key,
required this.icon,
required this.value,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Icon(
icon,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium,
),
),
],
);
}
}