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:
@@ -0,0 +1,171 @@
|
||||
/// Hand-written client for `/api/v1/tax`.
|
||||
///
|
||||
/// **Temporary.** The tax routes are not in `openapi/openapi.json` yet, so
|
||||
/// `app/packages/api_client` has no generated models or methods for them. Everything here
|
||||
/// follows `docs/ai/phase4-contract.md` §5 literally and is meant to be **replaced by the
|
||||
/// generated client** once the routes land in the spec and `just gen-client` runs.
|
||||
///
|
||||
/// Raw Dio comes from `ref.read(apiProvider).dio`: base URL, bearer header and the one-shot
|
||||
/// refresh on 401 are already wired there.
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../core/utils/json.dart';
|
||||
|
||||
/// Per-account (or total) tax figures for a year. Every number is an **estimate**: the tax
|
||||
/// agent is the broker, and these exist so that the broker's statement can be checked.
|
||||
class TaxRow {
|
||||
const TaxRow({
|
||||
this.accountId,
|
||||
this.accountName,
|
||||
this.dividendsGrossRub,
|
||||
this.couponsGrossRub,
|
||||
this.taxWithheldRub,
|
||||
this.realizedGainRub,
|
||||
this.realizedLossRub,
|
||||
this.ldvExemptRub,
|
||||
this.taxableBaseRub,
|
||||
this.estimatedTaxRub,
|
||||
});
|
||||
|
||||
final int? accountId;
|
||||
final String? accountName;
|
||||
final String? dividendsGrossRub;
|
||||
final String? couponsGrossRub;
|
||||
final String? taxWithheldRub;
|
||||
final String? realizedGainRub;
|
||||
final String? realizedLossRub;
|
||||
final String? ldvExemptRub;
|
||||
final String? taxableBaseRub;
|
||||
final String? estimatedTaxRub;
|
||||
|
||||
String get title => accountName ?? (accountId == null ? 'Итого' : 'Счёт #$accountId');
|
||||
|
||||
static TaxRow fromJson(Map<String, dynamic> json) => TaxRow(
|
||||
accountId: asInt(json['account_id']),
|
||||
accountName: asString(json['account_name']),
|
||||
dividendsGrossRub: asString(json['dividends_gross_rub']),
|
||||
couponsGrossRub: asString(json['coupons_gross_rub']),
|
||||
taxWithheldRub: asString(json['tax_withheld_rub']),
|
||||
realizedGainRub: asString(json['realized_gain_rub']),
|
||||
realizedLossRub: asString(json['realized_loss_rub']),
|
||||
ldvExemptRub: asString(json['ldv_exempt_rub']),
|
||||
taxableBaseRub: asString(json['taxable_base_rub']),
|
||||
estimatedTaxRub: asString(json['estimated_tax_rub']),
|
||||
);
|
||||
}
|
||||
|
||||
class TaxSummary {
|
||||
const TaxSummary({
|
||||
required this.year,
|
||||
required this.estimated,
|
||||
this.taxRate,
|
||||
this.accounts = const [],
|
||||
this.totals,
|
||||
this.disclaimer,
|
||||
});
|
||||
|
||||
final int year;
|
||||
|
||||
/// Always true per the contract — and shown on screen, not hidden in a tooltip.
|
||||
final bool estimated;
|
||||
final String? taxRate;
|
||||
final List<TaxRow> accounts;
|
||||
final TaxRow? totals;
|
||||
final String? disclaimer;
|
||||
|
||||
static const defaultDisclaimer =
|
||||
'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.';
|
||||
|
||||
static TaxSummary fromJson(Map<String, dynamic> json) {
|
||||
final totals = asObject(json['totals']);
|
||||
return TaxSummary(
|
||||
year: asInt(json['year']) ?? DateTime.now().year,
|
||||
estimated: json.containsKey('estimated') ? asBool(json['estimated']) : true,
|
||||
taxRate: asString(json['tax_rate']),
|
||||
accounts: asObjects(json['accounts']).map(TaxRow.fromJson).toList(),
|
||||
totals: totals == null ? null : TaxRow.fromJson(totals),
|
||||
disclaimer: asString(json['disclaimer']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An open lot with the date after which a sale falls under ЛДВ (the three-year exemption).
|
||||
class TaxLot {
|
||||
const TaxLot({
|
||||
required this.lotId,
|
||||
required this.ldvEligible,
|
||||
this.instrumentId,
|
||||
this.ticker,
|
||||
this.accountId,
|
||||
this.openDate,
|
||||
this.qtyRemaining,
|
||||
this.costRub,
|
||||
this.marketValueRub,
|
||||
this.unrealizedGainRub,
|
||||
this.ldvDate,
|
||||
this.daysToLdv,
|
||||
this.taxIfSoldNowRub,
|
||||
});
|
||||
|
||||
final int lotId;
|
||||
final int? instrumentId;
|
||||
final String? ticker;
|
||||
final int? accountId;
|
||||
final DateTime? openDate;
|
||||
final String? qtyRemaining;
|
||||
final String? costRub;
|
||||
final String? marketValueRub;
|
||||
final String? unrealizedGainRub;
|
||||
final bool ldvEligible;
|
||||
final DateTime? ldvDate;
|
||||
final int? daysToLdv;
|
||||
final String? taxIfSoldNowRub;
|
||||
|
||||
/// Close enough to ЛДВ that selling now is an expensive mistake. Six months is the
|
||||
/// horizon at which a person can still decide to wait.
|
||||
bool get nearLdv => !ldvEligible && daysToLdv != null && daysToLdv! <= 183;
|
||||
|
||||
String get title => ticker ?? (instrumentId == null ? '#$lotId' : '#$instrumentId');
|
||||
|
||||
static TaxLot fromJson(Map<String, dynamic> json) => TaxLot(
|
||||
lotId: asInt(json['lot_id']) ?? 0,
|
||||
instrumentId: asInt(json['instrument_id']),
|
||||
ticker: asString(json['ticker']),
|
||||
accountId: asInt(json['account_id']),
|
||||
openDate: asDate(json['open_date']),
|
||||
qtyRemaining: asString(json['qty_remaining']),
|
||||
costRub: asString(json['cost_rub']),
|
||||
marketValueRub: asString(json['market_value_rub']),
|
||||
unrealizedGainRub: asString(json['unrealized_gain_rub']),
|
||||
ldvEligible: asBool(json['ldv_eligible']),
|
||||
ldvDate: asDate(json['ldv_date']),
|
||||
daysToLdv: asInt(json['days_to_ldv']),
|
||||
taxIfSoldNowRub: asString(json['tax_if_sold_now_rub']),
|
||||
);
|
||||
}
|
||||
|
||||
class TaxApi {
|
||||
const TaxApi(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const _base = '/api/v1/tax';
|
||||
|
||||
Future<TaxSummary> summary({required int year, int? accountId}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
_base,
|
||||
queryParameters: {'year': year, 'account_id': ?accountId},
|
||||
);
|
||||
return TaxSummary.fromJson(r.data ?? const {});
|
||||
}
|
||||
|
||||
Future<List<TaxLot>> lots({required int year, int? accountId}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'$_base/lots',
|
||||
queryParameters: {'year': year, 'account_id': ?accountId},
|
||||
);
|
||||
return asObjects((r.data ?? const {})['lots']).map(TaxLot.fromJson).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.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 formatQty, signColor;
|
||||
import 'data/tax_api.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
/// Лоты и ЛДВ: the practical screen of the phase — what a sale costs **today** versus what
|
||||
/// it costs after the three-year mark.
|
||||
///
|
||||
/// Lots close to the ЛДВ date are called out, because selling a lot 20 days early is the
|
||||
/// one mistake this screen exists to prevent.
|
||||
class TaxLotsTab extends ConsumerStatefulWidget {
|
||||
const TaxLotsTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TaxLotsTab> createState() => _TaxLotsTabState();
|
||||
}
|
||||
|
||||
class _TaxLotsTabState extends ConsumerState<TaxLotsTab> {
|
||||
bool _onlyNearLdv = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lots = ref.watch(taxLotsProvider);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.invalidate(taxLotsProvider),
|
||||
child: AsyncValueView(
|
||||
value: lots,
|
||||
onRetry: () => ref.invalidate(taxLotsProvider),
|
||||
data: (all) {
|
||||
final near = all.where((l) => l.nearLdv).toList();
|
||||
// a copy: the provider's list must not be reordered under other watchers
|
||||
final rows = [...(_onlyNearLdv ? near : all)];
|
||||
// soonest ЛДВ first among the lots that do not have it yet, eligible ones last
|
||||
rows.sort((a, b) {
|
||||
if (a.ldvEligible != b.ldvEligible) return a.ldvEligible ? 1 : -1;
|
||||
return (a.daysToLdv ?? 1 << 30).compareTo(b.daysToLdv ?? 1 << 30);
|
||||
});
|
||||
|
||||
if (all.isEmpty) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: const [
|
||||
SizedBox(height: 48),
|
||||
EmptyState(
|
||||
icon: Icons.inventory_2_outlined,
|
||||
message: 'Открытых лотов нет.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (near.isNotEmpty)
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.tertiaryContainer,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.schedule),
|
||||
title: Text('До ЛДВ меньше полугода: ${near.length} лот(ов)'),
|
||||
subtitle: const Text(
|
||||
'Продажа до этой даты облагается налогом на весь прирост.'),
|
||||
trailing: FilterChip(
|
||||
label: const Text('только они'),
|
||||
selected: _onlyNearLdv,
|
||||
onSelected: (v) => setState(() => _onlyNearLdv = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
title: 'Открытые лоты',
|
||||
subtitle: 'налог при продаже сегодня — оценка по ставке из сводки',
|
||||
child: _LotsTable(rows: rows),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LotsTable extends StatelessWidget {
|
||||
const _LotsTable({required this.rows});
|
||||
|
||||
final List<TaxLot> rows;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final headerStyle = theme.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), 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)),
|
||||
DataColumn(label: Text('Дней до ЛДВ', style: headerStyle), numeric: true),
|
||||
DataColumn(label: Text('Налог при продаже', style: headerStyle), numeric: true),
|
||||
],
|
||||
rows: [
|
||||
for (final l in rows)
|
||||
DataRow(
|
||||
color: l.nearLdv
|
||||
? WidgetStatePropertyAll(
|
||||
theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5))
|
||||
: null,
|
||||
onSelectChanged: l.instrumentId == null
|
||||
? null
|
||||
: (_) => context.push('/portfolio/instrument/${l.instrumentId}'),
|
||||
cells: [
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l.title),
|
||||
if (l.nearLdv) ...[
|
||||
const SizedBox(width: 6),
|
||||
Tooltip(
|
||||
message: 'До ЛДВ осталось ${l.daysToLdv} дн. — '
|
||||
'продажа сейчас облагается налогом полностью',
|
||||
child: Icon(Icons.schedule, size: 16, color: theme.colorScheme.error),
|
||||
),
|
||||
],
|
||||
],
|
||||
)),
|
||||
DataCell(Text(l.openDate == null ? '—' : ruDate(l.openDate!))),
|
||||
DataCell(Text(l.qtyRemaining == null ? '—' : formatQty(l.qtyRemaining!))),
|
||||
DataCell(l.costRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(l.costRub!, currency: 'RUB')),
|
||||
DataCell(l.marketValueRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(l.marketValueRub!, currency: 'RUB')),
|
||||
DataCell(l.unrealizedGainRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(
|
||||
l.unrealizedGainRub!,
|
||||
currency: 'RUB',
|
||||
style: TextStyle(color: signColor(context, l.unrealizedGainRub)),
|
||||
)),
|
||||
DataCell(l.ldvEligible
|
||||
? const Text('уже действует')
|
||||
: Text(l.ldvDate == null ? '—' : ruDate(l.ldvDate!))),
|
||||
DataCell(l.ldvEligible
|
||||
? const Text('—')
|
||||
: Text(
|
||||
l.daysToLdv == null ? '—' : '${l.daysToLdv}',
|
||||
style: l.nearLdv
|
||||
? TextStyle(
|
||||
color: theme.colorScheme.error, fontWeight: FontWeight.w600)
|
||||
: null,
|
||||
)),
|
||||
DataCell(l.taxIfSoldNowRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(l.taxIfSoldNowRub!, currency: 'RUB')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import 'data/tax_api.dart';
|
||||
|
||||
final taxApiProvider = Provider<TaxApi>((ref) => TaxApi(ref.watch(apiProvider).dio));
|
||||
|
||||
final taxYearProvider = StateProvider<int>((ref) => DateTime.now().year);
|
||||
|
||||
/// Optional account filter; null = all accounts.
|
||||
final taxAccountProvider = StateProvider<int?>((ref) => null);
|
||||
|
||||
final taxSummaryProvider = FutureProvider.autoDispose<TaxSummary>((ref) async {
|
||||
return ref.watch(taxApiProvider).summary(
|
||||
year: ref.watch(taxYearProvider),
|
||||
accountId: ref.watch(taxAccountProvider),
|
||||
);
|
||||
});
|
||||
|
||||
final taxLotsProvider = FutureProvider.autoDispose<List<TaxLot>>((ref) async {
|
||||
return ref.watch(taxApiProvider).lots(
|
||||
year: ref.watch(taxYearProvider),
|
||||
accountId: ref.watch(taxAccountProvider),
|
||||
);
|
||||
});
|
||||
|
||||
void invalidateTaxProviders(WidgetRef ref) {
|
||||
ref.invalidate(taxSummaryProvider);
|
||||
ref.invalidate(taxLotsProvider);
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'data/tax_api.dart';
|
||||
import 'lots_tab.dart';
|
||||
import 'providers.dart';
|
||||
import 'summary_tab.dart';
|
||||
|
||||
/// Налоги: the year summary and the open-lot list with ЛДВ dates.
|
||||
///
|
||||
/// The word «оценка» is on the screen itself, not in a tooltip: the tax agent is the
|
||||
/// broker, and these numbers exist so that the broker's statement can be checked against
|
||||
/// something — not to replace it.
|
||||
class TaxPage extends ConsumerWidget {
|
||||
const TaxPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Налоги (оценка)'),
|
||||
actions: [
|
||||
const _YearSelector(),
|
||||
IconButton(
|
||||
tooltip: 'Обновить',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => invalidateTaxProviders(ref),
|
||||
),
|
||||
],
|
||||
bottom: const TabBar(
|
||||
tabs: [Tab(text: 'Сводка за год'), Tab(text: 'Лоты и ЛДВ')],
|
||||
),
|
||||
),
|
||||
body: const Column(
|
||||
children: [
|
||||
EstimateBanner(),
|
||||
Expanded(child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()])),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The disclaimer, always visible above both tabs.
|
||||
class EstimateBanner extends ConsumerWidget {
|
||||
const EstimateBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final summary = ref.watch(taxSummaryProvider).valueOrNull;
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final text = summary?.disclaimer ?? TaxSummary.defaultDisclaimer;
|
||||
|
||||
return Material(
|
||||
color: scheme.tertiaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _YearSelector extends ConsumerWidget {
|
||||
const _YearSelector();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final year = ref.watch(taxYearProvider);
|
||||
final now = DateTime.now().year;
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: year,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
items: [
|
||||
for (var y = now; y >= now - 6; y--)
|
||||
DropdownMenuItem(value: y, child: Text('$y')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) ref.read(taxYearProvider.notifier).state = v;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user