Files
Casha/lib/features/dashboard/widgets/transaction_tile.dart
T
2026-03-29 15:42:11 +03:00

534 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../core/constants.dart';
import '../../../core/l10n/app_strings.dart';
import '../../../core/l10n/locale_provider.dart';
import '../../../shared/models/transaction.dart';
import '../../../shared/models/account.dart';
import '../../../shared/providers/amount_format_provider.dart';
import '../../../shared/utils/currency_utils.dart';
import '../../../shared/widgets/byn_sign.dart';
import '../../settings/provider.dart';
import '../provider.dart';
class TransactionTile extends ConsumerWidget {
final Transaction transaction;
const TransactionTile({super.key, required this.transaction});
Border? _themeBorder(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Border.all(
color: isDark ? Colors.white.withOpacity(0.08) : const Color(0xFFDDDDEE),
width: 1,
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final s = ref.watch(stringsProvider);
final fmt = ref.watch(amountFormatProvider);
final isTransfer = transaction.category == 'Transfer';
final isIncome = transaction.type == TransactionType.income;
final color = isTransfer
? const Color(0xFF7C6DED)
: (isIncome ? AppColors.income : AppColors.expense);
final catColor = isTransfer
? const Color(0xFF7C6DED)
: (AppCategories.colors[transaction.category] ?? AppColors.accent);
final catIcon = isTransfer
? Icons.swap_horiz_rounded
: (AppCategories.icons[transaction.category] ?? Icons.category_rounded);
// Check if we're on Total Balance page
final activeAccount = ref.watch(activeAccountProvider);
final displayCurrency =
activeAccount?.currency ?? ref.watch(currencyProvider).code;
final showConverted = transaction.currencyCode != displayCurrency;
final exchangeService = ref.watch(exchangeRateServiceProvider);
final convertedAmount = showConverted
? exchangeService.convert(
transaction.amount,
transaction.currencyCode,
displayCurrency,
)
: 0.0;
final displaySymbol = currencyMap[displayCurrency]?.symbol ?? '';
// Look up the account name by matching transaction.accountId
final accounts = ref.watch(accountsProvider).valueOrNull ?? [];
final txAccount = accounts.firstWhereOrNull(
(a) => a.id == transaction.accountId,
);
// Build account label with 10-character limit
String accountLabel = txAccount?.name ?? '';
if (accountLabel.length > 10) {
accountLabel = '${accountLabel.substring(0, 10)}...';
}
// Transfer pairing logic
final pairs = ref.watch(transferPairsProvider);
final counterpart = pairs[transaction.id];
return GestureDetector(
onTap: () => context.push('/add', extra: transaction),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: _themeBorder(context),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: catColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(catIcon, color: catColor, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: isTransfer
? _buildTransferLabel(
context,
ref,
counterpart,
accounts,
activeAccount,
)
: Text(
s.categoryLabel(transaction.category),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
),
),
if (!isTransfer &&
activeAccount == null &&
accountLabel.isNotEmpty) ...[
const SizedBox(width: 6),
_AccountTag(label: accountLabel),
],
],
),
if (transaction.note != null && transaction.note!.isNotEmpty)
Text(
transaction.note!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
overflow: TextOverflow.ellipsis,
)
else
Text(
DateFormat(
'd MMM yyyy · HH:mm',
s.dateLocale,
).format(transaction.date),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
transaction.currencyCode == 'BYN'
? Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
_getAmountPrefix(
isTransfer,
isIncome,
activeAccount,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: _getAmountColor(
context,
isTransfer,
isIncome,
activeAccount,
color,
),
fontWeight: FontWeight.w700,
),
),
BynSign(
fontSize: 14,
color: _getAmountColor(
context,
isTransfer,
isIncome,
activeAccount,
color,
),
),
const SizedBox(width: 2),
Text(
formatAmount('', transaction.amount, fmt),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: _getAmountColor(
context,
isTransfer,
isIncome,
activeAccount,
color,
),
fontWeight: FontWeight.w700,
),
),
],
)
: Text(
'${_getAmountPrefix(isTransfer, isIncome, activeAccount)}${formatAmount(transaction.currency, transaction.amount, fmt)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: _getAmountColor(
context,
isTransfer,
isIncome,
activeAccount,
color,
),
fontWeight: FontWeight.w700,
),
),
if (showConverted) ...[
if (displayCurrency == 'BYN')
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'',
style: TextStyle(
fontSize: 11,
color: color.withOpacity(0.5),
fontWeight: FontWeight.w400,
height: 1.3,
),
),
BynSign(fontSize: 11, color: color.withOpacity(0.5)),
const SizedBox(width: 2),
Text(
formatAmount('', convertedAmount, fmt),
style: TextStyle(
fontSize: 11,
color: color.withOpacity(0.5),
fontWeight: FontWeight.w400,
height: 1.3,
),
),
],
)
else
Text(
'${formatAmount(displaySymbol, convertedAmount, fmt)}',
style: TextStyle(
fontSize: 11,
color: color.withOpacity(0.5),
fontWeight: FontWeight.w400,
height: 1.3,
),
),
],
],
),
],
),
),
);
}
Widget _buildTransferLabel(
BuildContext context,
WidgetRef ref,
Transaction? counterpart,
List<Account> accounts,
Account? activeAccount,
) {
final s = ref.watch(stringsProvider);
if (counterpart == null) {
return Text(
s.transferLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
);
}
final isExpense = transaction.type == TransactionType.expense;
final sourceAccountId = isExpense
? transaction.accountId
: counterpart.accountId;
final destAccountId = isExpense
? counterpart.accountId
: transaction.accountId;
final sourceAccountName = _accountName(accounts, sourceAccountId);
final destAccountName = _accountName(accounts, destAccountId);
final onSurface = Theme.of(context).colorScheme.onSurface;
if (activeAccount == null) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
s.transferLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: onSurface,
),
),
const SizedBox(width: 6),
Flexible(
child: _TransferChip(
label: '$sourceAccountName$destAccountName',
icon: Icons.swap_horiz_rounded,
),
),
],
);
}
if (isExpense) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
s.transferLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: onSurface,
),
),
const SizedBox(width: 6),
Text(
s.transferTo,
style: TextStyle(
fontSize: 11,
color: onSurface.withOpacity(0.45),
fontWeight: FontWeight.w400,
),
),
const SizedBox(width: 4),
Flexible(child: _TransferChip(label: destAccountName, icon: null)),
],
);
} else {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
s.transferLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: onSurface,
),
),
const SizedBox(width: 6),
Text(
s.transferFrom,
style: TextStyle(
fontSize: 11,
color: onSurface.withOpacity(0.45),
fontWeight: FontWeight.w400,
),
),
const SizedBox(width: 4),
Flexible(child: _TransferChip(label: sourceAccountName, icon: null)),
],
);
}
}
String _accountName(List<Account> accounts, int? id) {
if (id == null) return '?';
return accounts.firstWhereOrNull((a) => a.id == id)?.name ?? '?';
}
String _getAmountPrefix(
bool isTransfer,
bool isIncome,
Account? activeAccount,
) {
// Total Balance view with Transfer expense: no prefix
if (isTransfer && activeAccount == null && !isIncome) {
return '';
}
// All other cases: show + or
return isIncome ? '+ ' : '\u2212 ';
}
Color _getAmountColor(
BuildContext context,
bool isTransfer,
bool isIncome,
Account? activeAccount,
Color defaultColor,
) {
// Total Balance view with Transfer expense: neutral color
if (isTransfer && activeAccount == null && !isIncome) {
return Theme.of(context).colorScheme.onSurface.withOpacity(0.8);
}
// Transfer in account view or Total Balance income: use income/expense colors
if (isTransfer) {
return isIncome ? AppColors.income : AppColors.expense;
}
// Non-transfer: use default color
return defaultColor;
}
}
class _AccountTag extends StatelessWidget {
final String label;
const _AccountTag({required this.label});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return IntrinsicWidth(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: isDark
? Colors.white.withOpacity(0.08)
: const Color(0xFFF0EFFE),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: isDark
? Colors.white.withOpacity(0.12)
: const Color(0xFFD0CAFF),
width: 1,
),
),
child: Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: isDark
? Colors.white.withOpacity(0.55)
: const Color(0xFF7C6DED),
letterSpacing: 0.1,
),
),
),
);
}
}
class _TransferChip extends StatelessWidget {
final String label;
final IconData? icon;
const _TransferChip({required this.label, this.icon});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF7C6DED).withOpacity(0.12),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color(0xFF7C6DED).withOpacity(0.35),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 11, color: const Color(0xFF7C6DED)),
const SizedBox(width: 4),
],
Flexible(
child: Text(
label,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF7C6DED),
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
),
);
}
}
class EmptyState extends StatelessWidget {
final AppStrings strings;
const EmptyState({super.key, required this.strings});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
),
child: Icon(
Icons.receipt_long_rounded,
size: 48,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(height: 16),
Text(
strings.noTransactions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
strings.addFirstTx,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
);
}
}