feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков

Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
This commit is contained in:
Dmitry
2026-09-19 10:44:38 +03:00
parent 15f5812ea4
commit b69bb4a0c9
52 changed files with 7404 additions and 13 deletions
+243
View File
@@ -0,0 +1,243 @@
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}')),
],
),
],
),
);
}
}