Files
Dmitry 322c60a359 style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
2026-09-19 22:14:27 +03:00

239 lines
8.0 KiB
Dart

import 'package:flutter/material.dart';
import '../../core/widgets/help_tip.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: (cached) {
final data = cached.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: TermLabel('Счёт', style: headerStyle)),
DataColumn(
label: TermLabel(
'Дивиденды',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Купоны',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Удержано',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Прибыль',
hint: 'Сумма положительных результатов по проданным бумагам за год (до вычета убытков).',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Убыток',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'ЛДВ',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'База',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
DataColumn(
label: TermLabel(
'Налог (оценка)',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
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)),
],
);
}
}