mirror of
https://github.com/koloideal/Casha.git
synced 2026-06-10 18:35:28 +03:00
538 lines
18 KiB
Dart
538 lines
18 KiB
Dart
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,
|
||
) {
|
||
// Fallback if no counterpart
|
||
if (counterpart == null) {
|
||
return Text(
|
||
'Transfer',
|
||
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;
|
||
|
||
// Total Balance view (activeAccount == null), showing expense side
|
||
if (activeAccount == null) {
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'Transfer',
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// Account view
|
||
if (isExpense) {
|
||
// Expense side: Transfer to <destAccountName>
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'Transfer',
|
||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
color: onSurface,
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
'to',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: onSurface.withOpacity(0.45),
|
||
fontWeight: FontWeight.w400,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Flexible(child: _TransferChip(label: destAccountName, icon: null)),
|
||
],
|
||
);
|
||
} else {
|
||
// Income side: Transfer from <sourceAccountName>
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'Transfer',
|
||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
color: onSurface,
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
'from',
|
||
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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|