Files
fin-tracker/app/lib/features/income/history_tab.dart
T
Dmitry b69bb4a0c9 feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
2026-09-19 10:44:38 +03:00

244 lines
8.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:decimal/decimal.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/widgets/section_card.dart';
import 'data/income_api.dart';
import 'labels.dart';
import 'providers.dart';
/// История: what was actually paid, by month and kind. Facts only — no basis column here,
/// because every row is `paid` by construction.
class IncomeHistoryTab extends ConsumerWidget {
const IncomeHistoryTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final history = ref.watch(incomeHistoryProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(incomeHistoryProvider),
child: AsyncValueView(
value: history,
onRetry: () => ref.invalidate(incomeHistoryProvider),
data: (data) {
if (data.rows.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
_PeriodChips(),
SizedBox(height: 48),
EmptyState(
icon: Icons.history,
message: 'Выплат за период не было.',
),
],
);
}
final months = _byMonth(data.rows);
return ListView(
padding: const EdgeInsets.all(16),
children: [
const _PeriodChips(),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
StatTile(
label: 'Получено',
value: MoneyText(data.totalRub, currency: 'RUB'),
note: 'за выбранный период, до налога',
),
StatTile(
label: 'Удержано налога',
value: MoneyText(data.taxWithheldRub, currency: 'RUB'),
note: 'по данным брокера',
),
StatTile(
label: 'Месяцев с выплатами',
value: Text('${months.length}'),
note: 'строк в таблице: ${data.rows.length}',
),
],
),
const SizedBox(height: 16),
SectionCard(
title: 'Выплаты по месяцам',
child: SizedBox(
height: 220,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: SizedBox(
width: (months.length * 44).toDouble().clamp(320, double.infinity),
child: _HistoryChart(months: months),
),
),
),
),
const SizedBox(height: 16),
SectionCard(
title: 'Помесячно',
child: _HistoryTable(rows: data.rows),
),
],
);
},
),
);
}
}
class _PeriodChips extends ConsumerWidget {
const _PeriodChips();
@override
Widget build(BuildContext context, WidgetRef ref) {
final months = ref.watch(historyMonthsProvider);
return Wrap(
spacing: 8,
children: [
for (final m in const [12, 24, 36, 120])
ChoiceChip(
label: Text(m >= 120 ? 'Всё время' : '$m мес'),
selected: months == m,
onSelected: (_) => ref.read(historyMonthsProvider.notifier).state = m,
),
],
);
}
}
/// One bar per month, in rubles. Rows of several kinds inside a month are summed for the
/// chart; the breakdown by kind stays in the table below.
class _MonthTotal {
_MonthTotal(this.month, this.amountRub);
final DateTime month;
final Decimal amountRub;
}
List<_MonthTotal> _byMonth(List<IncomeHistoryRow> rows) {
final sums = <DateTime, Decimal>{};
for (final r in rows) {
final m = r.month;
if (m == null) continue;
final key = DateTime.utc(m.year, m.month);
sums[key] = (sums[key] ?? Decimal.zero) + (Decimal.tryParse(r.amountRub ?? '') ?? Decimal.zero);
}
final keys = sums.keys.toList()..sort();
return [for (final k in keys) _MonthTotal(k, sums[k]!)];
}
class _HistoryChart extends StatelessWidget {
const _HistoryChart({required this.months});
final List<_MonthTotal> months;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final maxY = months.fold<double>(0, (m, r) {
final v = r.amountRub.toDouble();
return v > m ? v : m;
});
return BarChart(
BarChartData(
maxY: maxY == 0 ? 1 : maxY * 1.15,
gridData: const FlGridData(show: true, drawVerticalLine: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(),
rightTitles: const AxisTitles(),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: true, reservedSize: 56),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
getTitlesWidget: (value, meta) {
final i = value.round();
if (i < 0 || i >= months.length) return const SizedBox.shrink();
if (months.length > 14 && i % 3 != 0) return const SizedBox.shrink();
return Text(ruMonthYearShort(months[i].month), style: theme.textTheme.bodySmall);
},
),
),
),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, _, rod, _) => BarTooltipItem(
'${ruMonthYearShort(months[group.x].month)}\n'
'${MoneyText.format(months[group.x].amountRub.toString(), 'RUB')}',
theme.textTheme.bodySmall ?? const TextStyle(),
),
),
),
barGroups: [
for (var i = 0; i < months.length; i++)
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: months[i].amountRub.toDouble(),
color: basisColor('paid'),
width: 12,
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)),
),
],
),
],
),
);
}
}
class _HistoryTable extends StatelessWidget {
const _HistoryTable({required this.rows});
final List<IncomeHistoryRow> 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)),
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),
],
rows: [
for (final r in rows)
DataRow(
cells: [
DataCell(Text(r.month == null ? '—' : ruMonthYearShort(r.month!))),
DataCell(Text(incomeKindLabel(r.kind))),
DataCell(Text(r.currency)),
DataCell(MoneyText(r.amount, currency: r.currency)),
DataCell(r.amountRub == null
? const Text('—')
: MoneyText(r.amountRub!, currency: 'RUB')),
DataCell(r.taxWithheld == null
? const Text('—')
: MoneyText(r.taxWithheld!, currency: r.currency)),
DataCell(Text('${r.paymentCount}')),
],
),
],
),
);
}
}