feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила

Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar
на узком. Токены в flutter_secure_storage, на web access живёт в памяти.
Интерцептор подставляет токен и делает ровно один refresh на 401.

Деньги приходят строками и форматируются через Decimal: парсить их в double
значило бы терять копейки ровно там, где бэкенд их бережёт.
This commit is contained in:
Dmitry
2026-09-18 13:44:09 +03:00
parent b9c12fa1a1
commit ce0966fca2
99 changed files with 6284 additions and 0 deletions
@@ -0,0 +1,83 @@
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),
),
],
),
);
}
}