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:
@@ -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()),
|
||||
);
|
||||
Reference in New Issue
Block a user