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,208 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
const _roleOrder = [AccountRole.liquid, AccountRole.savings, AccountRole.investment, AccountRole.debt];
|
||||
|
||||
String _roleLabel(AccountRole role) => switch (role) {
|
||||
AccountRole.liquid => 'Ликвидные',
|
||||
AccountRole.savings => 'Сбережения',
|
||||
AccountRole.investment => 'Инвестиции',
|
||||
AccountRole.debt => 'Долги',
|
||||
AccountRole.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
String _kindLabel(AccountKind kind) => switch (kind) {
|
||||
AccountKind.zmCash => 'Наличные',
|
||||
AccountKind.zmCard => 'Карта',
|
||||
AccountKind.zmChecking => 'Расчётный счёт',
|
||||
AccountKind.zmDeposit => 'Вклад',
|
||||
AccountKind.zmLoan => 'Кредит',
|
||||
AccountKind.zmEmoney => 'Электронные деньги',
|
||||
AccountKind.zmDebt => 'Долг',
|
||||
AccountKind.broker => 'Брокерский счёт',
|
||||
AccountKind.manualAsset => 'Актив вручную',
|
||||
AccountKind.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
/// Счета: every account grouped by role, with in-place role and
|
||||
/// include-in-net-worth edits (`PATCH /accounts/{id}`). Archived accounts
|
||||
/// collapse into their own section at the bottom regardless of role.
|
||||
class AccountsPage extends ConsumerWidget {
|
||||
const AccountsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final accounts = ref.watch(accountsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Счета')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(accountsProvider),
|
||||
child: AsyncValueView(
|
||||
value: accounts,
|
||||
onRetry: () => ref.invalidate(accountsProvider),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
EmptyState(
|
||||
icon: Icons.account_balance_outlined,
|
||||
message: 'Счетов ещё нет — нужна синхронизация.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final active = rows.where((a) => !a.archived).toList();
|
||||
final archived = rows.where((a) => a.archived).toList();
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final role in _roleOrder)
|
||||
if (active.any((a) => a.role == role))
|
||||
_RoleSection(
|
||||
title: _roleLabel(role),
|
||||
accounts: active.where((a) => a.role == role).toList(),
|
||||
),
|
||||
if (archived.isNotEmpty)
|
||||
ExpansionTile(
|
||||
title: Text('Архивные (${archived.length})'),
|
||||
initiallyExpanded: false,
|
||||
children: [for (final a in archived) _AccountTile(account: a)],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleSection extends StatelessWidget {
|
||||
const _RoleSection({required this.title, required this.accounts});
|
||||
|
||||
final String title;
|
||||
final List<AccountOut> accounts;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
for (final a in accounts) _AccountTile(account: a),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountTile extends ConsumerStatefulWidget {
|
||||
const _AccountTile({required this.account});
|
||||
|
||||
final AccountOut account;
|
||||
|
||||
@override
|
||||
ConsumerState<_AccountTile> createState() => _AccountTileState();
|
||||
}
|
||||
|
||||
class _AccountTileState extends ConsumerState<_AccountTile> {
|
||||
bool _saving = false;
|
||||
|
||||
Future<void> _patch(AccountPatch patch) async {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(apiProvider).getAccountsApi().accountsPatch(
|
||||
accountId: widget.account.id,
|
||||
accountPatch: patch,
|
||||
);
|
||||
ref.invalidate(accountsProvider);
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final a = widget.account;
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(a.name, style: Theme.of(context).textTheme.titleSmall),
|
||||
Text(
|
||||
'${_kindLabel(a.kind)} · ${a.currency}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MoneyText(
|
||||
a.balance ?? '0',
|
||||
currency: a.currency,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<AccountRole>(
|
||||
initialValue: a.role,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Роль', isDense: true),
|
||||
items: [
|
||||
for (final r in _roleOrder)
|
||||
DropdownMenuItem(value: r, child: Text(_roleLabel(r))),
|
||||
],
|
||||
onChanged: _saving ? null : (role) {
|
||||
if (role != null) _patch(AccountPatch(role: role));
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
children: [
|
||||
const Text('В капитал', style: TextStyle(fontSize: 11)),
|
||||
Switch(
|
||||
value: a.includeInNetWorth,
|
||||
onChanged: _saving
|
||||
? null
|
||||
: (v) => _patch(AccountPatch(includeInNetWorth: v)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
/// Every account, archived included — screens decide what to show.
|
||||
final accountsProvider = FutureProvider.autoDispose<List<AccountOut>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getAccountsApi().accountsList();
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
/// `account_id -> name`, for screens that only carry the id (transactions, rules).
|
||||
final accountNamesProvider = Provider.autoDispose<Map<int, String>>((ref) {
|
||||
final accounts = ref.watch(accountsProvider).valueOrNull ?? const [];
|
||||
return {for (final a in accounts) a.id: a.name};
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.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';
|
||||
|
||||
const _incomeColor = ChartColors.income;
|
||||
const _expenseColor = ChartColors.expense;
|
||||
|
||||
double _d(String s) => Decimal.parse(s).toDouble();
|
||||
|
||||
/// Потоки: 24 months of cashflow, a grouped bar chart on top and the full
|
||||
/// breakdown as a scrollable table below. Tapping a row opens Категории for
|
||||
/// that month.
|
||||
class CashflowPage extends ConsumerWidget {
|
||||
const CashflowPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final monthly = ref.watch(cashflowMonthly24Provider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Потоки')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(cashflowMonthly24Provider),
|
||||
child: AsyncValueView(
|
||||
value: monthly,
|
||||
onRetry: () => ref.invalidate(cashflowMonthly24Provider),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
EmptyState(
|
||||
icon: Icons.swap_horiz_outlined,
|
||||
message: 'Данных о потоках ещё нет — нужна синхронизация.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const _Legend(),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 240,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
reverse: true,
|
||||
child: SizedBox(
|
||||
width: (rows.length * 56).toDouble().clamp(320, double.infinity),
|
||||
child: _CashflowChart(rows: rows),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_Table(rows: rows),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Legend extends StatelessWidget {
|
||||
const _Legend();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: const [
|
||||
_LegendDot(color: _incomeColor, label: 'Доход'),
|
||||
SizedBox(width: 16),
|
||||
_LegendDot(color: _expenseColor, label: 'Расход'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendDot extends StatelessWidget {
|
||||
const _LegendDot({required this.color, required this.label});
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CashflowChart extends StatelessWidget {
|
||||
const _CashflowChart({required this.rows});
|
||||
|
||||
final List<CashFlowMonth> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxY = rows.fold<double>(
|
||||
0,
|
||||
(m, r) => [m, _d(r.incomeRub), _d(r.expenseRub)].reduce((a, b) => a > b ? a : b),
|
||||
);
|
||||
return BarChart(
|
||||
BarChartData(
|
||||
maxY: maxY * 1.1,
|
||||
gridData: const FlGridData(drawVerticalLine: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.toInt();
|
||||
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 10)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
barTouchData: BarTouchData(
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
final label = rodIndex == 0 ? 'Доход' : 'Расход';
|
||||
return BarTooltipItem(
|
||||
'$label\n${MoneyText.format(rod.toY.toStringAsFixed(2), 'RUB')}',
|
||||
const TextStyle(color: Colors.white, fontSize: 12),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
barGroups: [
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(toY: _d(rows[i].incomeRub), color: _incomeColor, width: 8),
|
||||
BarChartRodData(toY: _d(rows[i].expenseRub), color: _expenseColor, width: 8),
|
||||
],
|
||||
barsSpace: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Table extends StatelessWidget {
|
||||
const _Table({required this.rows});
|
||||
|
||||
final List<CashFlowMonth> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final headerStyle = Theme.of(context).textTheme.labelMedium;
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: DataTable(
|
||||
columns: [
|
||||
DataColumn(label: Text('Месяц', style: headerStyle)),
|
||||
DataColumn(label: Text('Доход', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Расход', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Базовые', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Разовые', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('В сбережения', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Норма сбер., %', style: headerStyle), numeric: true),
|
||||
],
|
||||
rows: [
|
||||
for (final r in rows.reversed)
|
||||
DataRow(
|
||||
onSelectChanged: (_) => context.go('/categories?month=${monthKey(r.month)}'),
|
||||
cells: [
|
||||
DataCell(Text(ruMonthYearShort(r.month))),
|
||||
DataCell(MoneyText(r.incomeRub, currency: 'RUB')),
|
||||
DataCell(MoneyText(r.expenseRub, currency: 'RUB')),
|
||||
DataCell(MoneyText(r.baselineRub, currency: 'RUB')),
|
||||
DataCell(MoneyText(r.oneOffRub, currency: 'RUB')),
|
||||
DataCell(MoneyText(r.savingsTransferRub, currency: 'RUB')),
|
||||
DataCell(Text(r.savingsRate == null
|
||||
? '—'
|
||||
: '${(_d(r.savingsRate!) * 100).toStringAsFixed(1)}%')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
/// The last 24 months, oldest first (as the API returns them).
|
||||
final cashflowMonthly24Provider = FutureProvider.autoDispose<List<CashFlowMonth>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 24);
|
||||
return r.data ?? const [];
|
||||
});
|
||||
@@ -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()),
|
||||
);
|
||||
@@ -0,0 +1,478 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:decimal/decimal.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/auth/auth_controller.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 '../../core/api/api_client.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
double _d(String s) => Decimal.parse(s).toDouble();
|
||||
|
||||
Color _severityColor(String severity) {
|
||||
final s = severity.toLowerCase();
|
||||
if (s.contains('crit') || s.contains('err')) return const Color(0xFFD03B3B);
|
||||
if (s.contains('warn')) return const Color(0xFFFAB219);
|
||||
if (s.contains('info') || s.contains('low')) return const Color(0xFF0CA30C);
|
||||
return const Color(0xFFEC835A);
|
||||
}
|
||||
|
||||
/// Обзор: the dashboard landing page — net worth, this month's cashflow,
|
||||
/// runway, a net worth line chart, a 12-month income/expense bar chart, and
|
||||
/// a data-quality summary linking to the findings.
|
||||
class HomePage extends ConsumerStatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage> {
|
||||
bool _refreshing = false;
|
||||
|
||||
Future<void> _refresh() async {
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
await ref.read(apiProvider).getMetricsApi().metricsRefresh();
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _refreshing = false);
|
||||
invalidateHomeProviders(ref);
|
||||
}
|
||||
}
|
||||
|
||||
void _showDataQuality(List<DataQualityRow> rows) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Качество данных', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
if (rows.isEmpty) const Text('Проблем не найдено.'),
|
||||
for (final row in rows)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 4, right: 8),
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration:
|
||||
BoxDecoration(color: _severityColor(row.severity), shape: BoxShape.circle),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${row.checkName} (${row.count})',
|
||||
style: Theme.of(context).textTheme.titleSmall),
|
||||
Text(row.detail),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final breakdown = ref.watch(netWorthBreakdownProvider);
|
||||
final series = ref.watch(netWorthSeriesProvider);
|
||||
final thisMonth = ref.watch(cashflowThisMonthProvider);
|
||||
final last12 = ref.watch(cashflowLast12Provider);
|
||||
final runway = ref.watch(runwayProvider);
|
||||
final status = ref.watch(metricsStatusProvider);
|
||||
final dataQuality = ref.watch(dataQualityProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Обзор'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Пересчитать метрики',
|
||||
icon: _refreshing
|
||||
? const SizedBox(
|
||||
width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _refreshing ? null : _refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => invalidateHomeProviders(ref),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AsyncValueView(
|
||||
value: status,
|
||||
onRetry: () => ref.invalidate(metricsStatusProvider),
|
||||
data: (log) {
|
||||
final at = log?.finishedAt ?? log?.startedAt;
|
||||
final text = at == null
|
||||
? 'Данные ещё не пересчитывались'
|
||||
: 'Данные на ${ruDate(at.toLocal())} ${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}';
|
||||
return Text(text, style: Theme.of(context).textTheme.bodySmall);
|
||||
},
|
||||
),
|
||||
),
|
||||
AsyncValueView(
|
||||
value: dataQuality,
|
||||
data: (rows) => ActionChip(
|
||||
avatar: Icon(
|
||||
rows.isEmpty ? Icons.check_circle_outline : Icons.warning_amber_outlined,
|
||||
size: 18,
|
||||
color: rows.isEmpty ? Colors.green : _severityColor(rows.first.severity),
|
||||
),
|
||||
label: Text(rows.isEmpty ? 'ок' : '${rows.length} замечаний'),
|
||||
onPressed: rows.isEmpty ? null : () => _showDataQuality(rows),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
AsyncValueView(
|
||||
value: breakdown,
|
||||
onRetry: () => ref.invalidate(netWorthBreakdownProvider),
|
||||
data: (b) => _NetWorthTiles(breakdown: b),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AsyncValueView(
|
||||
value: thisMonth,
|
||||
onRetry: () => ref.invalidate(cashflowThisMonthProvider),
|
||||
data: (m) => _MonthTiles(month: m),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
AsyncValueView(
|
||||
value: runway,
|
||||
onRetry: () => ref.invalidate(runwayProvider),
|
||||
data: (r) => _RunwayTile(runway: r),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final wide = constraints.maxWidth >= 900;
|
||||
final netWorthChart = _ChartCard(
|
||||
title: 'Капитал за 365 дней',
|
||||
child: AsyncValueView(
|
||||
value: series,
|
||||
onRetry: () => ref.invalidate(netWorthSeriesProvider),
|
||||
data: (rows) => rows.isEmpty
|
||||
? const EmptyState(icon: Icons.show_chart, message: 'Пока нет данных.')
|
||||
: _NetWorthChart(rows: rows),
|
||||
),
|
||||
);
|
||||
final cashflowChart = _ChartCard(
|
||||
title: 'Доход и расход, 12 месяцев',
|
||||
child: AsyncValueView(
|
||||
value: last12,
|
||||
onRetry: () => ref.invalidate(cashflowLast12Provider),
|
||||
data: (rows) => rows.isEmpty
|
||||
? const EmptyState(icon: Icons.bar_chart, message: 'Пока нет данных.')
|
||||
: _IncomeExpenseChart(rows: rows),
|
||||
),
|
||||
);
|
||||
if (wide) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: netWorthChart),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: cashflowChart),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [netWorthChart, const SizedBox(height: 16), cashflowChart],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => context.go('/sync'),
|
||||
icon: const Icon(Icons.sync, size: 16),
|
||||
label: const Text('Синхронизация'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatTile extends StatelessWidget {
|
||||
const _StatTile({required this.label, required this.value});
|
||||
final String label;
|
||||
final Widget value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 168,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(height: 4),
|
||||
DefaultTextStyle(style: Theme.of(context).textTheme.titleMedium!, child: value),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NetWorthTiles extends StatelessWidget {
|
||||
const _NetWorthTiles({required this.breakdown});
|
||||
final NetWorthBreakdown breakdown;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_StatTile(label: 'Капитал сегодня', value: MoneyText(breakdown.totalRub, currency: 'RUB')),
|
||||
_StatTile(label: 'Ликвидные', value: MoneyText(breakdown.liquidRub, currency: 'RUB')),
|
||||
_StatTile(label: 'Сбережения', value: MoneyText(breakdown.savingsRub, currency: 'RUB')),
|
||||
_StatTile(label: 'Инвестиции', value: MoneyText(breakdown.investmentRub, currency: 'RUB')),
|
||||
_StatTile(label: 'Долги', value: MoneyText(breakdown.debtRub, currency: 'RUB')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MonthTiles extends StatelessWidget {
|
||||
const _MonthTiles({required this.month});
|
||||
final CashFlowMonth? month;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (month == null) {
|
||||
return const EmptyState(icon: Icons.event_note_outlined, message: 'Данных за этот месяц нет.');
|
||||
}
|
||||
final rateText = month!.savingsRate == null ? '—' : '${(_d(month!.savingsRate!) * 100).toStringAsFixed(1)}%';
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_StatTile(label: 'Доход в этом месяце', value: MoneyText(month!.incomeRub, currency: 'RUB')),
|
||||
_StatTile(label: 'Расход в этом месяце', value: MoneyText(month!.expenseRub, currency: 'RUB')),
|
||||
_StatTile(label: 'Норма сбережений', value: Text(rateText)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RunwayTile extends StatelessWidget {
|
||||
const _RunwayTile({required this.runway});
|
||||
final RunwayOut runway;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = runway.runwayMonths == null
|
||||
? '—'
|
||||
: '${_d(runway.runwayMonths!).toStringAsFixed(1)} мес.';
|
||||
return _StatTile(label: 'Запас хода (runway)', value: Text(text));
|
||||
}
|
||||
}
|
||||
|
||||
class _ChartCard extends StatelessWidget {
|
||||
const _ChartCard({required this.title, required this.child});
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(height: 200, child: child),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NetWorthChart extends StatelessWidget {
|
||||
const _NetWorthChart({required this.rows});
|
||||
final List<NetWorthDay> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final spots = [
|
||||
for (var i = 0; i < rows.length; i++) FlSpot(i.toDouble(), _d(rows[i].totalRub)),
|
||||
];
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
gridData: const FlGridData(drawVerticalLine: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: const FlTitlesData(
|
||||
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) => [
|
||||
for (final s in spots)
|
||||
LineTooltipItem(
|
||||
MoneyText.format(s.y.toStringAsFixed(2), 'RUB'),
|
||||
const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: false,
|
||||
barWidth: 2,
|
||||
color: ChartColors.slot1Blue,
|
||||
dotData: const FlDotData(show: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IncomeExpenseChart extends StatelessWidget {
|
||||
const _IncomeExpenseChart({required this.rows});
|
||||
final List<CashFlowMonth> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final maxY = rows.fold<double>(
|
||||
0,
|
||||
(m, r) => [m, _d(r.incomeRub), _d(r.expenseRub)].reduce((a, b) => a > b ? a : b),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
_LegendDot(color: ChartColors.income, label: 'Доход'),
|
||||
SizedBox(width: 16),
|
||||
_LegendDot(color: ChartColors.expense, label: 'Расход'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(
|
||||
child: BarChart(
|
||||
BarChartData(
|
||||
maxY: maxY * 1.1,
|
||||
gridData: const FlGridData(drawVerticalLine: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final i = value.toInt();
|
||||
if (i < 0 || i >= rows.length) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child:
|
||||
Text(ruMonthYearShort(rows[i].month), style: const TextStyle(fontSize: 9)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
barTouchData: BarTouchData(
|
||||
touchTooltipData: BarTouchTooltipData(
|
||||
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
||||
final label = rodIndex == 0 ? 'Доход' : 'Расход';
|
||||
return BarTooltipItem(
|
||||
'$label\n${MoneyText.format(rod.toY.toStringAsFixed(2), 'RUB')}',
|
||||
const TextStyle(color: Colors.white, fontSize: 12),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
barGroups: [
|
||||
for (var i = 0; i < rows.length; i++)
|
||||
BarChartGroupData(
|
||||
x: i,
|
||||
barRods: [
|
||||
BarChartRodData(toY: _d(rows[i].incomeRub), color: ChartColors.income, width: 6),
|
||||
BarChartRodData(toY: _d(rows[i].expenseRub), color: ChartColors.expense, width: 6),
|
||||
],
|
||||
barsSpace: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendDot extends StatelessWidget {
|
||||
const _LegendDot({required this.color, required this.label});
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
final netWorthBreakdownProvider = FutureProvider.autoDispose<NetWorthBreakdown>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getNetworthApi().networthBreakdown();
|
||||
return r.data!;
|
||||
});
|
||||
|
||||
/// Daily net worth for the last 365 days.
|
||||
final netWorthSeriesProvider = FutureProvider.autoDispose<List<NetWorthDay>>((ref) async {
|
||||
final now = DateTime.now();
|
||||
final r = await ref.watch(apiProvider).getNetworthApi().networthSeries(
|
||||
from: now.subtract(const Duration(days: 365)),
|
||||
to: now,
|
||||
);
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
/// The current (partial) month's cashflow, or null before any data exists.
|
||||
final cashflowThisMonthProvider = FutureProvider.autoDispose<CashFlowMonth?>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 1);
|
||||
final rows = r.data ?? const [];
|
||||
return rows.isEmpty ? null : rows.last;
|
||||
});
|
||||
|
||||
/// The last 12 months, for the income-vs-expense bar chart.
|
||||
final cashflowLast12Provider = FutureProvider.autoDispose<List<CashFlowMonth>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowMonthly(months: 12);
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
final runwayProvider = FutureProvider.autoDispose<RunwayOut>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getCashflowApi().cashflowRunway();
|
||||
return r.data!;
|
||||
});
|
||||
|
||||
/// When the metric tables were last rebuilt; null before the first refresh.
|
||||
final metricsStatusProvider = FutureProvider.autoDispose<RefreshLogOut?>((ref) async {
|
||||
try {
|
||||
final r = await ref.watch(apiProvider).getMetricsApi().metricsStatus();
|
||||
return r.data;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
|
||||
final dataQualityProvider = FutureProvider.autoDispose<List<DataQualityRow>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getMetricsApi().metricsDataQuality();
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
/// Every dashboard provider, refreshed together after a manual
|
||||
/// `POST /metrics/refresh` or a pull-to-refresh.
|
||||
void invalidateHomeProviders(WidgetRef ref) {
|
||||
ref.invalidate(netWorthBreakdownProvider);
|
||||
ref.invalidate(netWorthSeriesProvider);
|
||||
ref.invalidate(cashflowThisMonthProvider);
|
||||
ref.invalidate(cashflowLast12Provider);
|
||||
ref.invalidate(runwayProvider);
|
||||
ref.invalidate(metricsStatusProvider);
|
||||
ref.invalidate(dataQualityProvider);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/config.dart';
|
||||
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _email = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
String? _error;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_email.dispose();
|
||||
_password.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
final error = await ref
|
||||
.read(authControllerProvider.notifier)
|
||||
.login(_email.text.trim(), _password.text);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_error = error;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = ref.watch(authControllerProvider).status;
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: status == AuthStatus.unknown
|
||||
? const CircularProgressIndicator()
|
||||
: AutofillGroup(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('fin-tracker', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(apiBaseUrl(), style: Theme.of(context).textTheme.bodySmall),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _email,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _password,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
obscureText: true,
|
||||
onSubmitted: (_) => _busy ? null : _submit(),
|
||||
decoration: const InputDecoration(labelText: 'Пароль'),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _submit,
|
||||
child: _busy
|
||||
? const SizedBox.square(
|
||||
dimension: 18, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Войти'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
final rulesListProvider = FutureProvider.autoDispose<List<RuleOut>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getRulesApi().rulesList();
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
/// Enabled rules that matched nothing in the latest refresh.
|
||||
final rulesStaleProvider = FutureProvider.autoDispose<List<RuleOut>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getRulesApi().rulesStale();
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
final staleRuleIdsProvider = Provider.autoDispose<Set<int>>((ref) {
|
||||
final stale = ref.watch(rulesStaleProvider).valueOrNull ?? const [];
|
||||
return {for (final r in stale) r.id};
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
String ruleKindLabel(RuleKind k) => switch (k) {
|
||||
RuleKind.savings => 'Сбережения',
|
||||
RuleKind.oneOff => 'Разовая трата',
|
||||
RuleKind.category => 'Категория',
|
||||
RuleKind.payee => 'Плательщик',
|
||||
RuleKind.brokerTarget => 'Целевой брокер',
|
||||
RuleKind.ignore => 'Игнорировать',
|
||||
RuleKind.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
String ruleMatchTypeLabel(RuleMatchType t) => switch (t) {
|
||||
RuleMatchType.id => 'ID транзакции',
|
||||
RuleMatchType.payee => 'Плательщик',
|
||||
RuleMatchType.comment => 'Комментарий',
|
||||
RuleMatchType.category => 'Категория',
|
||||
RuleMatchType.mcc => 'MCC',
|
||||
RuleMatchType.account => 'Счёт',
|
||||
RuleMatchType.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
const _kinds = [
|
||||
RuleKind.savings,
|
||||
RuleKind.oneOff,
|
||||
RuleKind.category,
|
||||
RuleKind.payee,
|
||||
RuleKind.brokerTarget,
|
||||
RuleKind.ignore,
|
||||
];
|
||||
|
||||
const _matchTypes = [
|
||||
RuleMatchType.id,
|
||||
RuleMatchType.payee,
|
||||
RuleMatchType.comment,
|
||||
RuleMatchType.category,
|
||||
RuleMatchType.mcc,
|
||||
RuleMatchType.account,
|
||||
];
|
||||
|
||||
void _invalidateAll(WidgetRef ref) {
|
||||
ref.invalidate(rulesListProvider);
|
||||
ref.invalidate(rulesStaleProvider);
|
||||
}
|
||||
|
||||
/// Правила: list of categorisation rules, an add/edit dialog, and a manual
|
||||
/// "apply" trigger that re-runs the whole metric refresh.
|
||||
class RulesPage extends ConsumerWidget {
|
||||
const RulesPage({super.key});
|
||||
|
||||
Future<void> _apply(BuildContext context, WidgetRef ref) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final r = await ref.read(apiProvider).getRulesApi().rulesApply();
|
||||
final ok = r.data?.error == null;
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(ok ? 'Правила применены' : 'Ошибка применения: ${r.data?.error}'),
|
||||
));
|
||||
} on DioException catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
_invalidateAll(ref);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final rules = ref.watch(rulesListProvider);
|
||||
final staleIds = ref.watch(staleRuleIdsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Правила'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Применить правила',
|
||||
icon: const Icon(Icons.play_circle_outline),
|
||||
onPressed: () => _apply(context, ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => _RuleDialog(onSaved: () => _invalidateAll(ref)),
|
||||
),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => _invalidateAll(ref),
|
||||
child: AsyncValueView(
|
||||
value: rules,
|
||||
onRetry: () => ref.invalidate(rulesListProvider),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
EmptyState(icon: Icons.rule_folder_outlined, message: 'Правил ещё нет.'),
|
||||
],
|
||||
);
|
||||
}
|
||||
final sorted = [...rows]..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final rule in sorted)
|
||||
_RuleTile(
|
||||
rule: rule,
|
||||
stale: staleIds.contains(rule.id),
|
||||
onChanged: () => _invalidateAll(ref),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RuleTile extends ConsumerStatefulWidget {
|
||||
const _RuleTile({required this.rule, required this.stale, required this.onChanged});
|
||||
|
||||
final RuleOut rule;
|
||||
final bool stale;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
@override
|
||||
ConsumerState<_RuleTile> createState() => _RuleTileState();
|
||||
}
|
||||
|
||||
class _RuleTileState extends ConsumerState<_RuleTile> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _toggle(bool enabled) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await ref.read(apiProvider).getRulesApi().rulesPatch(
|
||||
ruleId: widget.rule.id,
|
||||
rulePatch: RulePatch(enabled: enabled),
|
||||
);
|
||||
widget.onChanged();
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Удалить правило?'),
|
||||
content: Text('«${widget.rule.pattern}» — действие необратимо.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await ref.read(apiProvider).getRulesApi().rulesDelete(ruleId: widget.rule.id);
|
||||
widget.onChanged();
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rule = widget.rule;
|
||||
return Card(
|
||||
child: ListTile(
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => _RuleDialog(existing: rule, onSaved: widget.onChanged),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Text(ruleKindLabel(rule.kind)),
|
||||
const SizedBox(width: 8),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(ruleMatchTypeLabel(rule.matchType)),
|
||||
),
|
||||
if (widget.stale) ...[
|
||||
const SizedBox(width: 8),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: const Text('устарело'),
|
||||
backgroundColor: Colors.amber.withValues(alpha: 0.2),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
[
|
||||
'${rule.pattern} → ${rule.value ?? '—'}',
|
||||
'совпадений: ${rule.matchCount}',
|
||||
if (rule.lastMatchedAt != null) 'последнее: ${ruDate(rule.lastMatchedAt!.toLocal())}',
|
||||
].join(' · '),
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Switch(value: rule.enabled, onChanged: _busy ? null : _toggle),
|
||||
IconButton(icon: const Icon(Icons.delete_outline), onPressed: _delete),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RuleDialog extends ConsumerStatefulWidget {
|
||||
const _RuleDialog({this.existing, required this.onSaved});
|
||||
|
||||
final RuleOut? existing;
|
||||
final VoidCallback onSaved;
|
||||
|
||||
@override
|
||||
ConsumerState<_RuleDialog> createState() => _RuleDialogState();
|
||||
}
|
||||
|
||||
class _RuleDialogState extends ConsumerState<_RuleDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late RuleKind _kind;
|
||||
late RuleMatchType _matchType;
|
||||
late final TextEditingController _pattern;
|
||||
late final TextEditingController _value;
|
||||
late final TextEditingController _note;
|
||||
late final TextEditingController _priority;
|
||||
late bool _enabled;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final e = widget.existing;
|
||||
_kind = e?.kind ?? RuleKind.category;
|
||||
_matchType = e?.matchType ?? RuleMatchType.payee;
|
||||
_pattern = TextEditingController(text: e?.pattern ?? '');
|
||||
_value = TextEditingController(text: e?.value ?? '');
|
||||
_note = TextEditingController(text: e?.note ?? '');
|
||||
_priority = TextEditingController(text: (e?.priority ?? 100).toString());
|
||||
_enabled = e?.enabled ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pattern.dispose();
|
||||
_value.dispose();
|
||||
_note.dispose();
|
||||
_priority.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
setState(() => _saving = true);
|
||||
final priority = int.tryParse(_priority.text.trim()) ?? 100;
|
||||
try {
|
||||
final api = ref.read(apiProvider).getRulesApi();
|
||||
if (widget.existing == null) {
|
||||
await api.rulesCreate(
|
||||
ruleCreate: RuleCreate(
|
||||
kind: _kind,
|
||||
matchType: _matchType,
|
||||
pattern: _pattern.text.trim(),
|
||||
value: _value.text.trim().isEmpty ? null : _value.text.trim(),
|
||||
note: _note.text.trim().isEmpty ? null : _note.text.trim(),
|
||||
priority: priority,
|
||||
enabled: _enabled,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await api.rulesPatch(
|
||||
ruleId: widget.existing!.id,
|
||||
rulePatch: RulePatch(
|
||||
kind: _kind,
|
||||
matchType: _matchType,
|
||||
pattern: _pattern.text.trim(),
|
||||
value: _value.text.trim().isEmpty ? null : _value.text.trim(),
|
||||
note: _note.text.trim().isEmpty ? null : _note.text.trim(),
|
||||
priority: priority,
|
||||
enabled: _enabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
widget.onSaved();
|
||||
if (mounted) Navigator.pop(context);
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.existing == null ? 'Новое правило' : 'Правило'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<RuleKind>(
|
||||
initialValue: _kind,
|
||||
decoration: const InputDecoration(labelText: 'Тип правила'),
|
||||
items: [for (final k in _kinds) DropdownMenuItem(value: k, child: Text(ruleKindLabel(k)))],
|
||||
onChanged: (v) => setState(() => _kind = v!),
|
||||
),
|
||||
DropdownButtonFormField<RuleMatchType>(
|
||||
initialValue: _matchType,
|
||||
decoration: const InputDecoration(labelText: 'Совпадение по'),
|
||||
items: [
|
||||
for (final t in _matchTypes)
|
||||
DropdownMenuItem(value: t, child: Text(ruleMatchTypeLabel(t))),
|
||||
],
|
||||
onChanged: (v) => setState(() => _matchType = v!),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _pattern,
|
||||
decoration: const InputDecoration(labelText: 'Шаблон (pattern)'),
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Обязательное поле' : null,
|
||||
),
|
||||
TextFormField(
|
||||
controller: _value,
|
||||
decoration: const InputDecoration(labelText: 'Значение (value)'),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _note,
|
||||
decoration: const InputDecoration(labelText: 'Заметка'),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _priority,
|
||||
decoration: const InputDecoration(labelText: 'Приоритет'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Включено'),
|
||||
value: _enabled,
|
||||
onChanged: (v) => setState(() => _enabled = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Отмена')),
|
||||
FilledButton(onPressed: _saving ? null : _save, child: const Text('Сохранить')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/common_providers.dart';
|
||||
import '../../core/app_info.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/config.dart';
|
||||
import '../../core/theme/theme_controller.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
|
||||
/// Настройки: API endpoint, signed-in account, theme and logout.
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final me = ref.watch(meProvider);
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Настройки')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
const ListTile(
|
||||
leading: Icon(Icons.dns_outlined),
|
||||
title: Text('Адрес API'),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 56, right: 16),
|
||||
title: SelectableText(apiBaseUrl()),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_circle_outlined),
|
||||
title: const Text('Аккаунт'),
|
||||
subtitle: AsyncValueView(
|
||||
value: me,
|
||||
data: (u) => Text(u.email),
|
||||
onRetry: () => ref.invalidate(meProvider),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.palette_outlined),
|
||||
title: Text('Тема'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SegmentedButton<ThemeMode>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: ThemeMode.system,
|
||||
label: Text('Системная'),
|
||||
icon: Icon(Icons.brightness_auto_outlined),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.light,
|
||||
label: Text('Светлая'),
|
||||
icon: Icon(Icons.light_mode_outlined),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: ThemeMode.dark,
|
||||
label: Text('Тёмная'),
|
||||
icon: Icon(Icons.dark_mode_outlined),
|
||||
),
|
||||
],
|
||||
selected: {themeMode},
|
||||
onSelectionChanged: (selection) =>
|
||||
ref.read(themeModeProvider.notifier).setMode(selection.first),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const ListTile(
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('Версия приложения'),
|
||||
subtitle: Text(appVersion),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
|
||||
title: Text('Выйти', style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
onTap: () => ref.read(authControllerProvider.notifier).logout(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class _Destination {
|
||||
const _Destination(this.path, this.icon, this.selectedIcon, this.label);
|
||||
final String path;
|
||||
final IconData icon;
|
||||
final IconData selectedIcon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
const _destinations = [
|
||||
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор'),
|
||||
_Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета'),
|
||||
_Destination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки'),
|
||||
_Destination('/categories', Icons.category_outlined, Icons.category, 'Категории'),
|
||||
_Destination(
|
||||
'/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции'),
|
||||
_Destination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила'),
|
||||
_Destination('/sync', Icons.sync_outlined, Icons.sync, 'Синк'),
|
||||
_Destination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки'),
|
||||
];
|
||||
|
||||
/// Breakpoints per the plan: bottom bar under 600, collapsed rail 600–1200,
|
||||
/// extended rail at 1200 and above.
|
||||
const _narrowBreakpoint = 600.0;
|
||||
const _wideBreakpoint = 1200.0;
|
||||
|
||||
/// Adaptive navigation shell around the current route: a bottom
|
||||
/// [NavigationBar] on narrow surfaces, a [NavigationRail] (collapsed or
|
||||
/// extended) otherwise.
|
||||
class AppShell extends StatelessWidget {
|
||||
const AppShell({required this.location, required this.child, super.key});
|
||||
|
||||
final String location;
|
||||
final Widget child;
|
||||
|
||||
int get _selectedIndex {
|
||||
final i = _destinations.indexWhere((d) => d.path == location);
|
||||
return i == -1 ? 0 : i;
|
||||
}
|
||||
|
||||
void _onSelect(BuildContext context, int index) {
|
||||
if (index != _selectedIndex) context.go(_destinations[index].path);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
|
||||
if (width < _narrowBreakpoint) {
|
||||
return Scaffold(
|
||||
body: SafeArea(child: child),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (i) => _onSelect(context, i),
|
||||
destinations: [
|
||||
for (final d in _destinations)
|
||||
NavigationDestination(
|
||||
icon: Icon(d.icon),
|
||||
selectedIcon: Icon(d.selectedIcon),
|
||||
label: d.label,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final extended = width >= _wideBreakpoint;
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
extended: extended,
|
||||
minExtendedWidth: 220,
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (i) => _onSelect(context, i),
|
||||
labelType: extended ? NavigationRailLabelType.none : NavigationRailLabelType.selected,
|
||||
destinations: [
|
||||
for (final d in _destinations)
|
||||
NavigationRailDestination(
|
||||
icon: Icon(d.icon),
|
||||
selectedIcon: Icon(d.selectedIcon),
|
||||
label: Text(d.label),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: SafeArea(child: child)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
|
||||
final _dateFmt = DateFormat('dd.MM.yyyy HH:mm');
|
||||
|
||||
final syncStatusProvider = FutureProvider.autoDispose<List<SourceStatus>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getSyncApi().syncStatus();
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
final syncRunsProvider = FutureProvider.autoDispose<List<SyncRunOut>>((ref) async {
|
||||
final r = await ref.watch(apiProvider).getSyncApi().syncRuns(limit: 20);
|
||||
return r.data ?? const [];
|
||||
});
|
||||
|
||||
const _pollInterval = Duration(seconds: 10);
|
||||
|
||||
/// Синк: source statuses with a manual trigger per source, and a log of the
|
||||
/// last 20 runs. Polls `/sync/status` every 10 s while this page is mounted.
|
||||
class SyncPage extends ConsumerStatefulWidget {
|
||||
const SyncPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<SyncPage> createState() => _SyncPageState();
|
||||
}
|
||||
|
||||
class _SyncPageState extends ConsumerState<SyncPage> {
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(_pollInterval, (_) {
|
||||
if (!mounted) return;
|
||||
ref.invalidate(syncStatusProvider);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _refreshAll() async {
|
||||
ref.invalidate(syncStatusProvider);
|
||||
ref.invalidate(syncRunsProvider);
|
||||
}
|
||||
|
||||
Future<void> _trigger(String source) async {
|
||||
try {
|
||||
await ref.read(apiProvider).getSyncApi().syncTrigger(source_: source);
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
ref.invalidate(syncStatusProvider);
|
||||
ref.invalidate(syncRunsProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = ref.watch(syncStatusProvider);
|
||||
final runs = ref.watch(syncRunsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Синк'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Обновить',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _refreshAll,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refreshAll,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text('Источники', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
AsyncValueView(
|
||||
value: status,
|
||||
onRetry: () => ref.invalidate(syncStatusProvider),
|
||||
data: (rows) => rows.isEmpty
|
||||
? const EmptyState(
|
||||
icon: Icons.cable_outlined,
|
||||
message: 'Источники данных ещё не подключены (фаза 1: ZenMoney, ЦБ).',
|
||||
)
|
||||
: Column(
|
||||
children: [for (final s in rows) _SourceCard(source: s, onTrigger: _trigger)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Последние запуски', style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
AsyncValueView(
|
||||
value: runs,
|
||||
onRetry: () => ref.invalidate(syncRunsProvider),
|
||||
data: (rows) => rows.isEmpty
|
||||
? const EmptyState(icon: Icons.history, message: 'Запусков ещё не было.')
|
||||
: Column(children: [for (final r in rows) _RunTile(run: r)]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceCard extends StatelessWidget {
|
||||
const _SourceCard({required this.source, required this.onTrigger});
|
||||
|
||||
final SourceStatus source;
|
||||
final Future<void> Function(String source) onTrigger;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text(source.source_, style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(width: 8),
|
||||
_statusChip(context, source.lastRunStatus),
|
||||
if (source.queued) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text('в очереди'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text([
|
||||
if (source.lastRunAt != null) 'запуск ${_dateFmt.format(source.lastRunAt!.toLocal())}',
|
||||
if (source.cursor != null) 'курсор ${_shortCursor(source.cursor!)}',
|
||||
].join(' · ')),
|
||||
),
|
||||
trailing: IconButton(
|
||||
tooltip: 'Запустить синхронизацию',
|
||||
icon: const Icon(Icons.sync),
|
||||
onPressed: source.queued ? null : () => onTrigger(source.source_),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RunTile extends StatelessWidget {
|
||||
const _RunTile({required this.run});
|
||||
|
||||
final SyncRunOut run;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final duration = run.finishedAt?.difference(run.startedAt);
|
||||
final subtitle = [
|
||||
_dateFmt.format(run.startedAt.toLocal()),
|
||||
duration != null ? _formatDuration(duration) : 'выполняется',
|
||||
].join(' · ');
|
||||
|
||||
final counts = run.counts;
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (counts != null && counts.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final e in counts.entries)
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text('${e.key}=${e.value}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (run.error != null && run.error!.isNotEmpty) {
|
||||
return Card(
|
||||
child: ExpansionTile(
|
||||
title: _runTitle(context),
|
||||
subtitle: Text(subtitle),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
content,
|
||||
const SizedBox(height: 8),
|
||||
Text(run.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_runTitle(context),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 6),
|
||||
child: Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
|
||||
),
|
||||
content,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _runTitle(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(run.source_, style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(width: 8),
|
||||
_statusChip(context, run.status),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _statusChip(BuildContext context, RunStatus? status) {
|
||||
if (status == null) {
|
||||
return const Chip(visualDensity: VisualDensity.compact, label: Text('нет данных'));
|
||||
}
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final (label, color) = switch (status) {
|
||||
RunStatus.ok => ('ok', Colors.green),
|
||||
RunStatus.error => ('error', scheme.error),
|
||||
RunStatus.running => ('running', Colors.amber.shade700),
|
||||
RunStatus.unknownDefaultOpenApi => ('неизвестно', scheme.outline),
|
||||
};
|
||||
return Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(label),
|
||||
backgroundColor: color.withValues(alpha: 0.15),
|
||||
labelStyle: TextStyle(color: color),
|
||||
side: BorderSide(color: color.withValues(alpha: 0.4)),
|
||||
);
|
||||
}
|
||||
|
||||
String _shortCursor(String cursor) {
|
||||
if (cursor.length <= 20) return cursor;
|
||||
return '${cursor.substring(0, 10)}…${cursor.substring(cursor.length - 6)}';
|
||||
}
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
if (d.inMinutes >= 1) return '${d.inMinutes} мин ${d.inSeconds % 60} с';
|
||||
return '${d.inSeconds} с';
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
|
||||
class TransactionsFilter {
|
||||
const TransactionsFilter({
|
||||
this.q,
|
||||
this.from,
|
||||
this.to,
|
||||
this.accountId,
|
||||
this.categoryId,
|
||||
this.flowType,
|
||||
});
|
||||
|
||||
final String? q;
|
||||
final DateTime? from;
|
||||
final DateTime? to;
|
||||
final int? accountId;
|
||||
final int? categoryId;
|
||||
final FlowType? flowType;
|
||||
|
||||
bool get isEmpty =>
|
||||
q == null && from == null && to == null && accountId == null && categoryId == null && flowType == null;
|
||||
|
||||
TransactionsFilter copyWith({
|
||||
String? Function()? q,
|
||||
DateTime? Function()? from,
|
||||
DateTime? Function()? to,
|
||||
int? Function()? accountId,
|
||||
int? Function()? categoryId,
|
||||
FlowType? Function()? flowType,
|
||||
}) {
|
||||
return TransactionsFilter(
|
||||
q: q != null ? q() : this.q,
|
||||
from: from != null ? from() : this.from,
|
||||
to: to != null ? to() : this.to,
|
||||
accountId: accountId != null ? accountId() : this.accountId,
|
||||
categoryId: categoryId != null ? categoryId() : this.categoryId,
|
||||
flowType: flowType != null ? flowType() : this.flowType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TransactionsState {
|
||||
const TransactionsState({
|
||||
this.items = const [],
|
||||
this.page = 1,
|
||||
this.pageSize = 50,
|
||||
this.total = 0,
|
||||
this.loading = false,
|
||||
this.loadingMore = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final List<TransactionOut> items;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
final int total;
|
||||
final bool loading;
|
||||
final bool loadingMore;
|
||||
final Object? error;
|
||||
|
||||
bool get hasMore => items.length < total;
|
||||
|
||||
TransactionsState copyWith({
|
||||
List<TransactionOut>? items,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
int? total,
|
||||
bool? loading,
|
||||
bool? loadingMore,
|
||||
Object? error,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return TransactionsState(
|
||||
items: items ?? this.items,
|
||||
page: page ?? this.page,
|
||||
pageSize: pageSize ?? this.pageSize,
|
||||
total: total ?? this.total,
|
||||
loading: loading ?? this.loading,
|
||||
loadingMore: loadingMore ?? this.loadingMore,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filters + paginated results for Операции. `refresh()` restarts from page 1
|
||||
/// (called on build and on every filter change); `loadMore()` appends the
|
||||
/// next page for infinite scroll / the "ещё" button.
|
||||
class TransactionsController extends Notifier<TransactionsState> {
|
||||
TransactionsFilter filter = const TransactionsFilter();
|
||||
|
||||
@override
|
||||
TransactionsState build() {
|
||||
Future.microtask(refresh);
|
||||
return const TransactionsState(loading: true);
|
||||
}
|
||||
|
||||
Future<void> setFilter(TransactionsFilter Function(TransactionsFilter) update) {
|
||||
filter = update(filter);
|
||||
return refresh();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = state.copyWith(loading: true, clearError: true);
|
||||
try {
|
||||
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList(
|
||||
from: filter.from,
|
||||
to: filter.to,
|
||||
accountId: filter.accountId,
|
||||
categoryId: filter.categoryId,
|
||||
flowType: filter.flowType,
|
||||
q: (filter.q == null || filter.q!.isEmpty) ? null : filter.q,
|
||||
page: 1,
|
||||
pageSize: state.pageSize,
|
||||
);
|
||||
final page = r.data!;
|
||||
state = TransactionsState(
|
||||
items: page.items,
|
||||
page: page.page,
|
||||
pageSize: page.pageSize,
|
||||
total: page.total,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
state = state.copyWith(loading: false, error: e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (state.loading || state.loadingMore || !state.hasMore) return;
|
||||
state = state.copyWith(loadingMore: true, clearError: true);
|
||||
try {
|
||||
final next = state.page + 1;
|
||||
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList(
|
||||
from: filter.from,
|
||||
to: filter.to,
|
||||
accountId: filter.accountId,
|
||||
categoryId: filter.categoryId,
|
||||
flowType: filter.flowType,
|
||||
q: (filter.q == null || filter.q!.isEmpty) ? null : filter.q,
|
||||
page: next,
|
||||
pageSize: state.pageSize,
|
||||
);
|
||||
final page = r.data!;
|
||||
state = state.copyWith(
|
||||
items: [...state.items, ...page.items],
|
||||
page: page.page,
|
||||
total: page.total,
|
||||
loadingMore: false,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
state = state.copyWith(loadingMore: false, error: e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final transactionsControllerProvider =
|
||||
NotifierProvider<TransactionsController, TransactionsState>(TransactionsController.new);
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../accounts/providers.dart';
|
||||
import '../categories/providers.dart';
|
||||
import 'providers.dart';
|
||||
import 'transaction_row.dart';
|
||||
|
||||
const _flowTypes = [
|
||||
FlowType.income,
|
||||
FlowType.expense,
|
||||
FlowType.internalTransfer,
|
||||
FlowType.savingsTransfer,
|
||||
];
|
||||
|
||||
String _flowTypeLabel(FlowType t) => switch (t) {
|
||||
FlowType.income => 'Доход',
|
||||
FlowType.expense => 'Расход',
|
||||
FlowType.internalTransfer => 'Перевод',
|
||||
FlowType.savingsTransfer => 'В сбережения',
|
||||
FlowType.brokerExternalFlow => 'Брокер',
|
||||
FlowType.other => 'Прочее',
|
||||
FlowType.deleted => 'Удалено',
|
||||
FlowType.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
/// Операции: filtered, paginated transaction list with infinite scroll and a
|
||||
/// detail bottom sheet per row.
|
||||
class TransactionsPage extends ConsumerStatefulWidget {
|
||||
const TransactionsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TransactionsPage> createState() => _TransactionsPageState();
|
||||
}
|
||||
|
||||
class _TransactionsPageState extends ConsumerState<TransactionsPage> {
|
||||
final _searchController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_searchController.dispose();
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) {
|
||||
ref.read(transactionsControllerProvider.notifier).loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 400), () {
|
||||
ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDateRange() async {
|
||||
final filter = ref.read(transactionsControllerProvider.notifier).filter;
|
||||
final now = DateTime.now();
|
||||
final picked = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: DateTime.utc(2015),
|
||||
lastDate: now,
|
||||
initialDateRange: filter.from != null && filter.to != null
|
||||
? DateTimeRange(start: filter.from!, end: filter.to!)
|
||||
: null,
|
||||
helpText: 'Период',
|
||||
);
|
||||
if (picked != null) {
|
||||
ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(from: () => picked.start, to: () => picked.end),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearDateRange() {
|
||||
ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(from: () => null, to: () => null),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDetail(TransactionOut t) {
|
||||
final accountNames = ref.read(accountNamesProvider);
|
||||
final categoryNames = ref.read(categoryNamesProvider);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _TransactionDetailSheet(
|
||||
transaction: t,
|
||||
accountNames: accountNames,
|
||||
categoryNames: categoryNames,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(transactionsControllerProvider);
|
||||
final controllerFilter = ref.watch(transactionsControllerProvider.notifier).filter;
|
||||
final categoryNames = ref.watch(categoryNamesProvider);
|
||||
final accounts = ref.watch(accountsProvider).valueOrNull ?? const <AccountOut>[];
|
||||
final categories = ref.watch(categoriesListProvider).valueOrNull ?? const <CategoryOut>[];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Операции')),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(
|
||||
prefixIcon: Icon(Icons.search),
|
||||
hintText: 'Поиск по плательщику или комментарию',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
InputChip(
|
||||
avatar: const Icon(Icons.date_range, size: 18),
|
||||
label: Text(
|
||||
controllerFilter.from != null && controllerFilter.to != null
|
||||
? '${ruDate(controllerFilter.from!)} – ${ruDate(controllerFilter.to!)}'
|
||||
: 'Период',
|
||||
),
|
||||
onPressed: _pickDateRange,
|
||||
onDeleted: controllerFilter.from != null ? _clearDateRange : null,
|
||||
),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonFormField<int?>(
|
||||
initialValue: controllerFilter.accountId,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Счёт', isDense: true),
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Все счета')),
|
||||
for (final a in accounts) DropdownMenuItem(value: a.id, child: Text(a.name)),
|
||||
],
|
||||
onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(accountId: () => v),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonFormField<int?>(
|
||||
initialValue: controllerFilter.categoryId,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Категория', isDense: true),
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Все категории')),
|
||||
for (final c in categories) DropdownMenuItem(value: c.id, child: Text(c.name)),
|
||||
],
|
||||
onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(categoryId: () => v),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('Все типы'),
|
||||
selected: controllerFilter.flowType == null,
|
||||
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(flowType: () => null),
|
||||
),
|
||||
),
|
||||
for (final ft in _flowTypes)
|
||||
ChoiceChip(
|
||||
label: Text(_flowTypeLabel(ft)),
|
||||
selected: controllerFilter.flowType == ft,
|
||||
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(flowType: () => ft),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: _buildList(state, categoryNames)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList(TransactionsState state, Map<int, String> categoryNames) {
|
||||
if (state.loading && state.items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state.error != null && state.items.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text('${state.error}', textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.read(transactionsControllerProvider.notifier).refresh(),
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.items.isEmpty) {
|
||||
return const EmptyState(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
message: 'Операций не найдено — попробуйте изменить фильтры или выполните синхронизацию.',
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.read(transactionsControllerProvider.notifier).refresh(),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: state.items.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == state.items.length) {
|
||||
if (!state.hasMore) return const SizedBox(height: 24);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: state.loadingMore
|
||||
? const CircularProgressIndicator()
|
||||
: TextButton(
|
||||
onPressed: () => ref.read(transactionsControllerProvider.notifier).loadMore(),
|
||||
child: const Text('Ещё'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final t = state.items[index];
|
||||
return TransactionRow(
|
||||
transaction: t,
|
||||
categoryName: t.categoryId != null ? categoryNames[t.categoryId] : null,
|
||||
onTap: () => _showDetail(t),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TransactionDetailSheet extends StatelessWidget {
|
||||
const _TransactionDetailSheet({
|
||||
required this.transaction,
|
||||
required this.accountNames,
|
||||
required this.categoryNames,
|
||||
});
|
||||
|
||||
final TransactionOut transaction;
|
||||
final Map<int, String> accountNames;
|
||||
final Map<int, String> categoryNames;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = transaction;
|
||||
final rows = <(String, String)>[
|
||||
('Дата', ruDate(t.date)),
|
||||
('Плательщик', t.payee ?? '—'),
|
||||
if (t.payeeCanonical != null && t.payeeCanonical != t.payee) ('Канонический', t.payeeCanonical!),
|
||||
('Комментарий', t.comment ?? '—'),
|
||||
('Категория', t.categoryId != null ? (categoryNames[t.categoryId] ?? '#${t.categoryId}') : 'Без категории'),
|
||||
('Тип', _flowTypeLabel(t.flowType)),
|
||||
if (t.outcome != '0')
|
||||
('Списание', '${MoneyText.format(t.outcome, t.outcomeCurrency ?? 'RUB')}'
|
||||
'${t.outcomeRub != null ? ' (${MoneyText.format(t.outcomeRub!, 'RUB')})' : ''}'),
|
||||
if (t.income != '0')
|
||||
('Зачисление', '${MoneyText.format(t.income, t.incomeCurrency ?? 'RUB')}'
|
||||
'${t.incomeRub != null ? ' (${MoneyText.format(t.incomeRub!, 'RUB')})' : ''}'),
|
||||
if (t.outcomeAccountId != null)
|
||||
('Счёт списания', accountNames[t.outcomeAccountId] ?? '#${t.outcomeAccountId}'),
|
||||
if (t.incomeAccountId != null)
|
||||
('Счёт зачисления', accountNames[t.incomeAccountId] ?? '#${t.incomeAccountId}'),
|
||||
if (t.mcc != null) ('MCC', '${t.mcc}'),
|
||||
('Удержание (hold)', t.hold ? 'да' : 'нет'),
|
||||
('Разовая трата', t.isOneOff ? 'да' : 'нет'),
|
||||
if (t.tags.isNotEmpty) ('Теги', t.tags.map((id) => categoryNames[id] ?? '#$id').join(', ')),
|
||||
if (t.tripId != null) ('Поездка', '#${t.tripId}'),
|
||||
('Источник (id)', t.sourceId),
|
||||
];
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Операция', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 12),
|
||||
for (final (label, value) in rows)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 140, child: Text(label, style: Theme.of(context).textTheme.bodySmall)),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user