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,248 @@
import 'package:decimal/decimal.dart';
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/chart_colors.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import 'providers.dart';
class _Group {
_Group(this.rootId, this.rootName);
final int? rootId;
final String rootName;
final List<SpendingRow> rows = [];
Decimal get total => rows.fold(Decimal.zero, (a, r) => a + Decimal.parse(r.amountRub));
}
List<_Group> _group(List<SpendingRow> rows) {
final byRoot = <int?, _Group>{};
for (final r in rows) {
final key = r.categoryId == null ? null : (r.rootCategoryId ?? r.categoryId);
final group = byRoot.putIfAbsent(
key,
() => _Group(key, key == null ? 'Без категории' : (r.rootCategoryName ?? r.categoryName ?? '')),
);
group.rows.add(r);
}
final groups = byRoot.values.toList()..sort((a, b) => b.total.compareTo(a.total));
return groups;
}
/// Категории: expenses of one month, root-grouped, largest first.
class CategoriesPage extends ConsumerStatefulWidget {
const CategoriesPage({this.initialMonth, super.key});
final String? initialMonth;
@override
ConsumerState<CategoriesPage> createState() => _CategoriesPageState();
}
class _CategoriesPageState extends ConsumerState<CategoriesPage> {
@override
void initState() {
super.initState();
final initial = widget.initialMonth;
if (initial != null) {
Future.microtask(() => ref.read(selectedSpendingMonthProvider.notifier).state = initial);
}
}
Future<void> _pickMonth() async {
final current = parseMonthKey(ref.read(selectedSpendingMonthProvider));
final picked = await showDatePicker(
context: context,
initialDate: current,
firstDate: DateTime.utc(2015),
lastDate: DateTime.now(),
helpText: 'Выберите месяц',
initialDatePickerMode: DatePickerMode.year,
);
if (picked != null) {
ref.read(selectedSpendingMonthProvider.notifier).state =
monthKey(DateTime(picked.year, picked.month));
}
}
void _shiftMonth(int delta) {
final current = parseMonthKey(ref.read(selectedSpendingMonthProvider));
final next = DateTime.utc(current.year, current.month + delta);
ref.read(selectedSpendingMonthProvider.notifier).state = monthKey(next);
}
@override
Widget build(BuildContext context) {
final month = ref.watch(selectedSpendingMonthProvider);
final spending = ref.watch(spendingProvider(month));
return Scaffold(
appBar: AppBar(title: const Text('Категории')),
body: RefreshIndicator(
onRefresh: () async => ref.invalidate(spendingProvider(month)),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () => _shiftMonth(-1),
),
TextButton(
onPressed: _pickMonth,
child: Text(
ruMonthYear(parseMonthKey(month)),
style: Theme.of(context).textTheme.titleMedium,
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () => _shiftMonth(1),
),
],
),
const SizedBox(height: 8),
AsyncValueView(
value: spending,
onRetry: () => ref.invalidate(spendingProvider(month)),
data: (rows) {
if (rows.isEmpty) {
return const EmptyState(
icon: Icons.donut_small_outlined,
message: 'Данных за этот месяц нет — нужна синхронизация.',
);
}
final groups = _group(rows);
final total = groups.fold(Decimal.zero, (a, g) => a + g.total);
final maxTotal = groups.first.total;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Всего расходов', style: Theme.of(context).textTheme.bodyLarge),
MoneyText(
total.toString(),
currency: 'RUB',
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
const Divider(),
for (final g in groups) _GroupTile(group: g, maxTotal: maxTotal),
],
);
},
),
],
),
),
);
}
}
class _GroupTile extends StatelessWidget {
const _GroupTile({required this.group, required this.maxTotal});
final _Group group;
final Decimal maxTotal;
bool get _flat =>
group.rows.length == 1 && (group.rootId == null || group.rows.first.categoryId == group.rootId);
@override
Widget build(BuildContext context) {
if (_flat) {
return _CategoryBar(
name: group.rootName,
amount: group.total,
maxAmount: maxTotal,
bold: true,
);
}
final children = [...group.rows]
..sort((a, b) => Decimal.parse(b.amountRub).compareTo(Decimal.parse(a.amountRub)));
return ExpansionTile(
tilePadding: EdgeInsets.zero,
title: _CategoryBar(name: group.rootName, amount: group.total, maxAmount: maxTotal, bold: true),
children: [
for (final r in children)
Padding(
padding: const EdgeInsets.only(left: 16),
child: _CategoryBar(
name: r.categoryId == group.rootId ? 'Без подкатегории' : (r.categoryName ?? ''),
amount: Decimal.parse(r.amountRub),
maxAmount: group.total,
bold: false,
),
),
],
);
}
}
class _CategoryBar extends StatelessWidget {
const _CategoryBar({
required this.name,
required this.amount,
required this.maxAmount,
required this.bold,
});
final String name;
final Decimal amount;
final Decimal maxAmount;
final bool bold;
@override
Widget build(BuildContext context) {
final ratio = maxAmount == Decimal.zero
? 0.0
: (amount / maxAmount).toDouble().clamp(0.0, 1.0);
final scheme = Theme.of(context).colorScheme;
final style = bold
? Theme.of(context).textTheme.bodyLarge
: Theme.of(context).textTheme.bodyMedium;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text(name, style: style)),
MoneyText(amount.toString(), currency: 'RUB', style: style),
],
),
const SizedBox(height: 4),
LayoutBuilder(
builder: (context, constraints) => ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Stack(
children: [
Container(height: 6, color: scheme.surfaceContainerHighest),
Container(
height: 6,
width: constraints.maxWidth * ratio,
color: bold ? ChartColors.expense : ChartColors.expense.withValues(alpha: 0.6),
),
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,29 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/utils/ru_date.dart';
/// Flat ZenMoney tag tree — the client nests it by `parent_id` where needed.
final categoriesListProvider = FutureProvider.autoDispose<List<CategoryOut>>((ref) async {
final r = await ref.watch(apiProvider).getCategoriesApi().categoriesList();
return r.data ?? const [];
});
/// `category_id -> name`, for screens that only carry the id.
final categoryNamesProvider = Provider.autoDispose<Map<int, String>>((ref) {
final categories = ref.watch(categoriesListProvider).valueOrNull ?? const [];
return {for (final c in categories) c.id: c.name};
});
/// Spending by category for one month (`YYYY-MM`); null means "the latest month".
final spendingProvider =
FutureProvider.autoDispose.family<List<SpendingRow>, String?>((ref, month) async {
final r = await ref.watch(apiProvider).getCashflowApi().cashflowSpending(month: month);
return r.data ?? const [];
});
/// The month currently selected on the Категории screen, `YYYY-MM`.
final selectedSpendingMonthProvider = StateProvider.autoDispose<String>(
(ref) => monthKey(DateTime.now()),
);