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
+337
View File
@@ -0,0 +1,337 @@
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 '../portfolio/labels.dart' show formatPercent;
import 'data/income_api.dart';
import 'labels.dart';
import 'providers.dart';
/// Прогноз: expected income for the next N months, **always split by basis**.
///
/// The stacked bar and the table both carry the split rather than the total alone: a month
/// built entirely out of `history` is an extrapolation, and a month built out of `announced`
/// is nearly a fact. One number cannot say which.
class IncomeForecastTab extends ConsumerWidget {
const IncomeForecastTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final forecast = ref.watch(incomeForecastProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(incomeForecastProvider),
child: AsyncValueView(
value: forecast,
onRetry: () => ref.invalidate(incomeForecastProvider),
data: (data) {
final bases = data.bases;
return ListView(
padding: const EdgeInsets.all(16),
children: [
const _HorizonChips(),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
StatTile(
label: 'Ожидается всего',
value: MoneyText(data.totalRub, currency: 'RUB'),
note: 'за ${data.months.length} мес, до налога',
),
StatTile(
label: 'Доходность к стоимости',
// null means "нет оценки стоимости" and must not read as 0 %
value: Text(formatPercent(data.annualYieldOnValue, signed: false)),
note: data.annualYieldOnValue == null
? 'нет оценки текущей стоимости'
: 'ожидаемый доход / стоимость портфеля',
),
for (final b in bases)
StatTile(
label: 'Основание: ${basisLabel(b)}',
value: MoneyText(_basisTotal(data, b).toString(), currency: 'RUB'),
note: basisDescription(b),
width: 220,
),
],
),
if (data.warnings.isNotEmpty) ...[
const SizedBox(height: 16),
_Warnings(warnings: data.warnings),
],
const SizedBox(height: 16),
if (data.months.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyState(
icon: Icons.insights_outlined,
message: 'Прогнозировать нечего: ни объявленных выплат, ни истории.',
),
)
else ...[
SectionCard(
title: 'По месяцам',
subtitle: 'цвет столбца — основание прогноза',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_BasisLegend(bases: bases),
const SizedBox(height: 12),
SizedBox(
height: 220,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
width: (data.months.length * 52).toDouble().clamp(320, double.infinity),
child: _ForecastChart(months: data.months, bases: bases),
),
),
),
],
),
),
const SizedBox(height: 16),
SectionCard(
title: 'Разбивка по основанию',
child: _ForecastTable(months: data.months, bases: bases),
),
],
],
);
},
),
);
}
}
Decimal _basisTotal(IncomeForecast data, String basis) => data.months
.map((m) => Decimal.tryParse(m.byBasis[basis] ?? '0') ?? Decimal.zero)
.fold(Decimal.zero, (a, b) => a + b);
class _HorizonChips extends ConsumerWidget {
const _HorizonChips();
@override
Widget build(BuildContext context, WidgetRef ref) {
final months = ref.watch(forecastMonthsProvider);
return Wrap(
spacing: 8,
children: [
for (final m in const [6, 12, 24, 36])
ChoiceChip(
label: Text('$m мес'),
selected: months == m,
onSelected: (_) => ref.read(forecastMonthsProvider.notifier).state = m,
),
],
);
}
}
class _Warnings extends StatelessWidget {
const _Warnings({required this.warnings});
final List<String> warnings;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Card(
color: scheme.tertiaryContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final w in warnings)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber_outlined, size: 16),
const SizedBox(width: 8),
Expanded(child: Text(w)),
],
),
),
],
),
),
);
}
}
class _BasisLegend extends StatelessWidget {
const _BasisLegend({required this.bases});
final List<String> bases;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 16,
runSpacing: 6,
children: [
for (final b in bases)
Tooltip(
message: basisDescription(b),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: basisColor(b), shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(basisLabel(b), style: Theme.of(context).textTheme.bodySmall),
],
),
),
],
);
}
}
class _ForecastChart extends StatelessWidget {
const _ForecastChart({required this.months, required this.bases});
final List<ForecastMonth> months;
final List<String> bases;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final maxY = months.fold<double>(0, (m, r) {
final v = (Decimal.tryParse(r.amountRub) ?? Decimal.zero).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.isOdd) return const SizedBox.shrink();
final m = months[i].month;
return Text(
m == null ? '' : ruMonthYearShort(m),
style: theme.textTheme.bodySmall,
);
},
),
),
),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, _, rod, _) {
final m = months[group.x];
final parts = [
for (final b in bases)
if ((Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) > Decimal.zero)
'${basisLabel(b)}: ${MoneyText.format(m.byBasis[b]!, 'RUB')}',
];
return BarTooltipItem(
'${m.month == null ? '' : ruMonthYearShort(m.month!)}\n'
'${MoneyText.format(m.amountRub, 'RUB')}'
'${parts.isEmpty ? '' : '\n${parts.join('\n')}'}',
theme.textTheme.bodySmall ?? const TextStyle(),
);
},
),
),
barGroups: [
for (var i = 0; i < months.length; i++)
BarChartGroupData(x: i, barRods: [_stackedRod(months[i])]),
],
),
);
}
/// One rod per month, stacked by basis: the height is the month's total, the segments are
/// where that total came from.
BarChartRodData _stackedRod(ForecastMonth m) {
final stack = <BarChartRodStackItem>[];
var from = 0.0;
for (final b in bases) {
final v = (Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero).toDouble();
if (v <= 0) continue;
stack.add(BarChartRodStackItem(from, from + v, basisColor(b)));
from += v;
}
final total = (Decimal.tryParse(m.amountRub) ?? Decimal.zero).toDouble();
return BarChartRodData(
toY: from > 0 ? from : total,
rodStackItems: stack,
color: Colors.transparent,
width: 16,
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)),
);
}
}
class _ForecastTable extends StatelessWidget {
const _ForecastTable({required this.months, required this.bases});
final List<ForecastMonth> months;
final List<String> bases;
@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)),
for (final b in bases)
DataColumn(
label: Tooltip(
message: basisDescription(b),
child: Text(basisLabel(b), style: headerStyle),
),
numeric: true,
),
DataColumn(label: Text('Итого', style: headerStyle), numeric: true),
],
rows: [
for (final m in months)
DataRow(
cells: [
DataCell(Text(m.month == null ? '' : ruMonthYearShort(m.month!))),
for (final b in bases)
DataCell(MoneyText(m.byBasis[b] ?? '0', currency: 'RUB')),
DataCell(MoneyText(
m.amountRub,
currency: 'RUB',
style: Theme.of(context).textTheme.titleSmall,
)),
],
),
],
),
);
}
}