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,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)}%')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user