style(app): остальные экраны под новый визуальный язык и форматирование

Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
This commit is contained in:
Dmitry
2026-09-19 22:14:27 +03:00
parent 62d36aa3e8
commit 322c60a359
55 changed files with 2158 additions and 1080 deletions
+43 -31
View File
@@ -41,20 +41,21 @@ class TaxRow {
final String? taxableBaseRub;
final String? estimatedTaxRub;
String get title => accountName ?? (accountId == null ? 'Итого' : 'Счёт #$accountId');
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']),
);
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 {
@@ -83,7 +84,9 @@ class TaxSummary {
final totals = asObject(json['totals']);
return TaxSummary(
year: asInt(json['year']) ?? DateTime.now().year,
estimated: json.containsKey('estimated') ? asBool(json['estimated']) : true,
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),
@@ -128,23 +131,24 @@ class TaxLot {
/// 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');
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']),
);
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 {
@@ -156,12 +160,18 @@ class TaxApi {
/// See `docs/ai/offline-cache.md`: this hand-written client returns `Cached<T>` directly
/// (there is no generated `Response<T>` for the screen to unwrap `r.cached` from).
Future<Cached<TaxSummary>> summary({required int year, int? accountId}) async {
Future<Cached<TaxSummary>> summary({
required int year,
int? accountId,
}) async {
final r = await _dio.get<Map<String, dynamic>>(
_base,
queryParameters: {'year': year, 'account_id': ?accountId},
);
return Cached(TaxSummary.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?);
return Cached(
TaxSummary.fromJson(r.data ?? const {}),
fetchedAt: r.extra['fetchedAt'] as DateTime?,
);
}
Future<Cached<List<TaxLot>>> lots({required int year, int? accountId}) async {
@@ -169,7 +179,9 @@ class TaxApi {
'$_base/lots',
queryParameters: {'year': year, 'account_id': ?accountId},
);
final lots = asObjects((r.data ?? const {})['lots']).map(TaxLot.fromJson).toList();
final lots = asObjects((r.data ?? const {})['lots'])
.map(TaxLot.fromJson)
.toList();
return Cached(lots, fetchedAt: r.extra['fetchedAt'] as DateTime?);
}
}
+136 -56
View File
@@ -1,4 +1,7 @@
import 'package:flutter/material.dart';
import '../../core/widgets/help_tip.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -67,9 +70,12 @@ class _TaxLotsTabState extends ConsumerState<TaxLotsTab> {
color: Theme.of(context).colorScheme.tertiaryContainer,
child: ListTile(
leading: const Icon(Icons.schedule),
title: Text('До ЛДВ меньше полугода: ${near.length} лот(ов)'),
title: Text(
'До ЛДВ меньше полугода: ${near.length} лот(ов)',
),
subtitle: const Text(
'Продажа до этой даты облагается налогом на весь прирост.'),
'Продажа до этой даты облагается налогом на весь прирост.',
),
trailing: FilterChip(
label: const Text('только они'),
selected: _onlyNearLdv,
@@ -80,7 +86,8 @@ class _TaxLotsTabState extends ConsumerState<TaxLotsTab> {
const SizedBox(height: 12),
SectionCard(
title: 'Открытые лоты',
subtitle: 'налог при продаже сегодня — оценка по ставке из сводки',
subtitle:
'налог при продаже сегодня — оценка по ставке из сводки',
child: _LotsTable(rows: rows),
),
],
@@ -104,71 +111,144 @@ class _LotsTable extends StatelessWidget {
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),
DataColumn(label: TermLabel('Бумага', style: headerStyle)),
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(
'Нереализ.',
style: headerStyle,
alignment: MainAxisAlignment.end,
),
numeric: true,
),
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,
),
],
rows: [
for (final l in rows)
DataRow(
color: l.nearLdv
? WidgetStatePropertyAll(
theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5))
theme.colorScheme.tertiaryContainer.withValues(
alpha: 0.5,
),
)
: null,
onSelectChanged: l.instrumentId == null
? null
: (_) => context.push('/portfolio/instrument/${l.instrumentId}'),
: (_) =>
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(
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')),
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'),
),
],
),
],
+15 -5
View File
@@ -4,7 +4,9 @@ import '../../core/api/api_client.dart';
import '../../core/cache/cached.dart';
import 'data/tax_api.dart';
final taxApiProvider = Provider<TaxApi>((ref) => TaxApi(ref.watch(apiProvider).dio));
final taxApiProvider = Provider<TaxApi>(
(ref) => TaxApi(ref.watch(apiProvider).dio),
);
final taxYearProvider = StateProvider<int>((ref) => DateTime.now().year);
@@ -12,15 +14,23 @@ final taxYearProvider = StateProvider<int>((ref) => DateTime.now().year);
final taxAccountProvider = StateProvider<int?>((ref) => null);
/// See `docs/ai/offline-cache.md`.
final taxSummaryProvider = FutureProvider.autoDispose<Cached<TaxSummary>>((ref) async {
return ref.watch(taxApiProvider).summary(
final taxSummaryProvider = FutureProvider.autoDispose<Cached<TaxSummary>>((
ref,
) async {
return ref
.watch(taxApiProvider)
.summary(
year: ref.watch(taxYearProvider),
accountId: ref.watch(taxAccountProvider),
);
});
final taxLotsProvider = FutureProvider.autoDispose<Cached<List<TaxLot>>>((ref) async {
return ref.watch(taxApiProvider).lots(
final taxLotsProvider = FutureProvider.autoDispose<Cached<List<TaxLot>>>((
ref,
) async {
return ref
.watch(taxApiProvider)
.lots(
year: ref.watch(taxYearProvider),
accountId: ref.watch(taxAccountProvider),
);
+81 -12
View File
@@ -1,4 +1,7 @@
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';
@@ -30,7 +33,10 @@ class TaxSummaryTab extends ConsumerWidget {
children: [
Row(
children: [
Text('${data.year} год', style: Theme.of(context).textTheme.titleLarge),
Text(
'${data.year} год',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(width: 12),
if (data.estimated)
const Chip(
@@ -40,7 +46,9 @@ class TaxSummaryTab extends ConsumerWidget {
),
const Spacer(),
if (data.taxRate != null)
Text('ставка ${formatPercent(data.taxRate, signed: false)}'),
Text(
'ставка ${formatPercent(data.taxRate, signed: false)}',
),
],
),
const SizedBox(height: 12),
@@ -124,15 +132,72 @@ class _AccountsTable extends StatelessWidget {
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),
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),
@@ -149,7 +214,11 @@ class _AccountsTable extends StatelessWidget {
: MoneyText(
v,
currency: 'RUB',
style: signed ? (style ?? const TextStyle()).copyWith(color: signColor(context, v)) : style,
style: signed
? (style ?? const TextStyle()).copyWith(
color: signColor(context, v),
)
: style,
);
return DataRow(
+8 -6
View File
@@ -37,7 +37,10 @@ class TaxPage extends ConsumerWidget {
),
],
bottom: const TabBar(
tabs: [Tab(text: 'Сводка за год'), Tab(text: 'Лоты и ЛДВ')],
tabs: [
Tab(text: 'Сводка за год'),
Tab(text: 'Лоты и ЛДВ'),
],
),
),
body: Column(
@@ -48,7 +51,9 @@ class TaxPage extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: StaleBanner(fetchedAt: stale),
),
const Expanded(child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()])),
const Expanded(
child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()]),
),
],
),
),
@@ -75,10 +80,7 @@ class EstimateBanner extends ConsumerWidget {
const Icon(Icons.info_outline, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall,
),
child: Text(text, style: Theme.of(context).textTheme.bodySmall),
),
],
),