accounts, cashflow, categories, goals, income, portfolio (+instrument), rebalance, tax, rules переведены на Cached<T> по контракту docs/ai/offline-cache.md. Общие/второстепенные селекторы (scopesProvider, categoriesListProvider и т.п.) оставлены как есть — не основной контент экрана. sync_page.dart и settings_page.dart не тронуты (первый — заменён health-page отдельно, второй — чистые действия без списка для баннера).
179 lines
7.2 KiB
Dart
179 lines
7.2 KiB
Dart
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: (cached) {
|
|
final all = cached.data;
|
|
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')),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|