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
+168
View File
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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, signColor;
import 'data/tax_api.dart';
import 'providers.dart';
/// Сводка: the estimated tax picture for the year, per account and in total.
class TaxSummaryTab extends ConsumerWidget {
const TaxSummaryTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref.watch(taxSummaryProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(taxSummaryProvider),
child: AsyncValueView(
value: summary,
onRetry: () => ref.invalidate(taxSummaryProvider),
data: (data) {
final totals = data.totals;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
children: [
Text('${data.year} год', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(width: 12),
if (data.estimated)
const Chip(
avatar: Icon(Icons.calculate_outlined, size: 16),
label: Text('оценка'),
visualDensity: VisualDensity.compact,
),
const Spacer(),
if (data.taxRate != null)
Text('ставка ${formatPercent(data.taxRate, signed: false)}'),
],
),
const SizedBox(height: 12),
if (totals != null) ...[
Wrap(
spacing: 12,
runSpacing: 12,
children: [
StatTile(
label: 'Оценка налога',
value: _money(totals.estimatedTaxRub),
note: 'по всем счетам за год',
),
StatTile(
label: 'Удержано брокером',
value: _money(totals.taxWithheldRub),
note: 'по данным операций',
),
StatTile(
label: 'Налоговая база',
value: _money(totals.taxableBaseRub),
note: 'после вычета ЛДВ',
),
StatTile(
label: 'Освобождено по ЛДВ',
value: _money(totals.ldvExemptRub),
note: 'оценка по ст. 219.1',
),
StatTile(
label: 'Дивиденды и купоны',
value: _money(totals.dividendsGrossRub),
note: 'купоны ${_moneyText(totals.couponsGrossRub)}',
),
StatTile(
label: 'Реализовано',
value: _money(totals.realizedGainRub),
note: 'убыток ${_moneyText(totals.realizedLossRub)}',
),
],
),
const SizedBox(height: 16),
],
if (data.accounts.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 32),
child: EmptyState(
icon: Icons.receipt_long_outlined,
message: 'За этот год нет ни сделок, ни выплат.',
),
)
else
SectionCard(
title: 'По счетам',
subtitle: 'все суммы — оценка; авторитет — справка брокера',
child: _AccountsTable(rows: data.accounts, totals: totals),
),
],
);
},
),
);
}
static Widget _money(String? value) =>
value == null ? const Text('') : MoneyText(value, currency: 'RUB');
static String _moneyText(String? value) =>
value == null ? '' : MoneyText.format(value, 'RUB');
}
class _AccountsTable extends StatelessWidget {
const _AccountsTable({required this.rows, this.totals});
final List<TaxRow> rows;
final TaxRow? totals;
@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),
DataColumn(label: Text('База', style: headerStyle), numeric: true),
DataColumn(label: Text('Налог (оценка)', style: headerStyle), numeric: true),
],
rows: [
for (final r in rows) _row(context, r, bold: false),
if (totals != null) _row(context, totals!, bold: true),
],
),
);
}
DataRow _row(BuildContext context, TaxRow r, {required bool bold}) {
final style = bold ? Theme.of(context).textTheme.titleSmall : null;
Widget cell(String? v, {bool signed = false}) => v == null
? Text('', style: style)
: MoneyText(
v,
currency: 'RUB',
style: signed ? (style ?? const TextStyle()).copyWith(color: signColor(context, v)) : style,
);
return DataRow(
cells: [
DataCell(Text(bold ? 'Итого' : r.title, style: style)),
DataCell(cell(r.dividendsGrossRub)),
DataCell(cell(r.couponsGrossRub)),
DataCell(cell(r.taxWithheldRub)),
DataCell(cell(r.realizedGainRub, signed: true)),
DataCell(cell(r.realizedLossRub, signed: true)),
DataCell(cell(r.ldvExemptRub)),
DataCell(cell(r.taxableBaseRub)),
DataCell(cell(r.estimatedTaxRub)),
],
);
}
}