Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar на узком. Токены в flutter_secure_storage, на web access живёт в памяти. Интерцептор подставляет токен и делает ровно один refresh на 401. Деньги приходят строками и форматируются через Decimal: парсить их в double значило бы терять копейки ровно там, где бэкенд их бережёт.
84 lines
3.1 KiB
Dart
84 lines
3.1 KiB
Dart
import 'package:fintracker_api/fintracker_api.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../core/utils/ru_date.dart';
|
|
import '../../core/widgets/money_text.dart';
|
|
|
|
/// The amount/currency/RUB-equivalent a transaction is shown with, chosen by
|
|
/// its [FlowType]: the outgoing leg for expenses and transfers out, the
|
|
/// incoming leg for income.
|
|
({String amount, String currency, String? rub}) primaryAmount(TransactionOut t) {
|
|
switch (t.flowType) {
|
|
case FlowType.income:
|
|
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub);
|
|
case FlowType.expense:
|
|
return (amount: t.outcome, currency: t.outcomeCurrency ?? 'RUB', rub: t.outcomeRub);
|
|
case FlowType.internalTransfer:
|
|
case FlowType.savingsTransfer:
|
|
case FlowType.brokerExternalFlow:
|
|
case FlowType.other:
|
|
case FlowType.deleted:
|
|
case FlowType.unknownDefaultOpenApi:
|
|
if (t.outcome != '0' && t.outcomeCurrency != null) {
|
|
return (amount: t.outcome, currency: t.outcomeCurrency!, rub: t.outcomeRub);
|
|
}
|
|
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub);
|
|
}
|
|
}
|
|
|
|
(IconData, Color) flowTypeIcon(FlowType type, ColorScheme scheme) => switch (type) {
|
|
FlowType.income => (Icons.arrow_circle_down_outlined, Colors.green),
|
|
FlowType.expense => (Icons.arrow_circle_up_outlined, scheme.error),
|
|
FlowType.internalTransfer => (Icons.swap_horiz, scheme.primary),
|
|
FlowType.savingsTransfer => (Icons.savings_outlined, Colors.amber.shade800),
|
|
FlowType.brokerExternalFlow => (Icons.trending_up, Colors.deepPurple),
|
|
FlowType.other || FlowType.deleted || FlowType.unknownDefaultOpenApi => (
|
|
Icons.help_outline,
|
|
scheme.outline,
|
|
),
|
|
};
|
|
|
|
/// One row of the Операции list: date, payee, category, native amount with
|
|
/// its RUB equivalent when the currency differs, and a flow-type icon.
|
|
class TransactionRow extends StatelessWidget {
|
|
const TransactionRow({
|
|
required this.transaction,
|
|
required this.categoryName,
|
|
required this.onTap,
|
|
super.key,
|
|
});
|
|
|
|
final TransactionOut transaction;
|
|
final String? categoryName;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = transaction;
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final (icon, color) = flowTypeIcon(t.flowType, scheme);
|
|
final amount = primaryAmount(t);
|
|
final showRub = amount.currency != 'RUB' && amount.rub != null;
|
|
final payee = t.payeeCanonical?.isNotEmpty == true ? t.payeeCanonical! : (t.payee ?? '—');
|
|
|
|
return ListTile(
|
|
onTap: onTap,
|
|
leading: Icon(icon, color: color),
|
|
title: Text(payee),
|
|
subtitle: Text([ruDate(t.date), ?categoryName].join(' · ')),
|
|
trailing: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
MoneyText(amount.amount, currency: amount.currency),
|
|
if (showRub)
|
|
Text(
|
|
MoneyText.format(amount.rub!, 'RUB'),
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.outline),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|