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

364 lines
12 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/rebalance_api.dart';
import 'labels.dart';
import 'providers.dart';
import 'weights.dart';
/// Рекомендации: what to buy and sell to get back to the target weights.
///
/// Three things are marked explicitly, because without them the numbers mislead:
/// `within_band` (no action needed — the drift is inside the corridor the user set),
/// `blocked_by_cash` (the quantity was cut because the cash ran out), and a null
/// `suggested_qty` (there is no price, so no quantity can be computed — an em dash, not 0).
class RebalancePlanTab extends ConsumerWidget {
const RebalancePlanTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final plan = ref.watch(rebalancePlanProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(rebalancePlanProvider),
child: AsyncValueView(
value: plan,
onRetry: () => ref.invalidate(rebalancePlanProvider),
data: (cached) {
final data = cached.data;
if (data == null) {
return const EmptyState(
icon: Icons.balance,
message: 'Портфель не выбран.',
);
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
_Header(plan: data),
if (data.warnings.isNotEmpty) ...[
const SizedBox(height: 12),
_Warnings(warnings: data.warnings),
],
const SizedBox(height: 16),
if (data.buckets.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 32),
child: EmptyState(
icon: Icons.balance,
message: 'Нечего показать: целевые веса по этому измерению не заданы.',
),
)
else if (data.everythingWithinBand)
Card(
color: Theme.of(context).colorScheme.secondaryContainer,
child: const ListTile(
leading: Icon(Icons.check_circle_outline),
title: Text('Все группы внутри коридора'),
subtitle: Text(
'Действий не требуется — отклонения меньше заданного допуска.',
),
),
),
const SizedBox(height: 8),
for (final b in data.buckets)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _BucketCard(dimension: data.dimension, bucket: b),
),
],
);
},
),
);
}
}
class _Header extends ConsumerWidget {
const _Header({required this.plan});
final RebalancePlan plan;
@override
Widget build(BuildContext context, WidgetRef ref) {
final whatIf = ref.watch(whatIfCashProvider);
return SectionCard(
title: 'Портфель',
subtitle: plan.asOf == null ? null : 'на ${ruDate(plan.asOf!)}',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 24,
runSpacing: 8,
children: [
_Figure(
label: 'Стоимость',
child: plan.totalValueRub == null
? const Text('—')
: MoneyText(plan.totalValueRub!, currency: 'RUB'),
),
_Figure(
label: whatIf == null
? 'Доступно денег'
: 'Доступно денег (what-if)',
child: plan.cashAvailableRub == null
? const Text('—')
: MoneyText(plan.cashAvailableRub!, currency: 'RUB'),
),
],
),
const SizedBox(height: 12),
_WhatIfCashField(current: whatIf),
],
),
);
}
}
class _Figure extends StatelessWidget {
const _Figure({required this.label, required this.child});
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: theme.textTheme.bodySmall),
DefaultTextStyle(style: theme.textTheme.titleMedium!, child: child),
],
);
}
}
class _WhatIfCashField extends ConsumerStatefulWidget {
const _WhatIfCashField({required this.current});
final String? current;
@override
ConsumerState<_WhatIfCashField> createState() => _WhatIfCashFieldState();
}
class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> {
late final TextEditingController _controller = TextEditingController(
text: widget.current ?? '',
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _apply() {
final text = _controller.text
.trim()
.replaceAll(',', '.')
.replaceAll(' ', '');
ref.read(whatIfCashProvider.notifier).state = text.isEmpty ? null : text;
}
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 220,
child: TextField(
controller: _controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Считать по другой сумме денег, ₽',
isDense: true,
helperText: 'пусто — реальный остаток на счетах',
),
onSubmitted: (_) => _apply(),
),
),
const SizedBox(width: 12),
TextButton(onPressed: _apply, child: const Text('Пересчитать')),
],
);
}
}
class _Warnings extends StatelessWidget {
const _Warnings({required this.warnings});
final List<String> warnings;
@override
Widget build(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.tertiaryContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final w in warnings)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber_outlined, size: 16),
const SizedBox(width: 8),
Expanded(child: Text(w)),
],
),
],
),
),
);
}
}
class _BucketCard extends StatelessWidget {
const _BucketCard({required this.dimension, required this.bucket});
final String dimension;
final RebalanceBucket bucket;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SectionCard(
title: bucketLabelForKey(dimension, bucket.bucket),
subtitle:
'сейчас ${formatShareAsPercent(bucket.currentWeight)} · '
'цель ${formatShareAsPercent(bucket.targetWeight)} · '
'отклонение ${formatShareAsPercent(bucket.drift, signed: true)}',
trailing: bucket.withinBand
? const _WithinBandChip()
: (bucket.deltaValueRub == null
? const Text('—')
: MoneyText(
bucket.deltaValueRub!,
currency: 'RUB',
style: TextStyle(
color: signColor(context, bucket.deltaValueRub),
),
)),
child: bucket.withinBand
? Text(
'Внутри коридора — сделок не требуется.',
style: theme.textTheme.bodySmall,
)
: bucket.trades.isEmpty
? Text(
'Сервер не предложил сделок по этой группе: '
'вероятно, у подходящих бумаг нет цены — см. предупреждения выше.',
style: theme.textTheme.bodySmall,
)
: Column(
children: [for (final t in bucket.trades) TradeRow(trade: t)],
),
);
}
}
class _WithinBandChip extends StatelessWidget {
const _WithinBandChip();
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: 'Отклонение не выходит за допуск (band) — действий не требуется',
child: Chip(
avatar: const Icon(Icons.check, size: 16),
label: const Text('в коридоре'),
backgroundColor: scheme.secondaryContainer,
visualDensity: VisualDensity.compact,
),
);
}
}
/// One suggested trade. Public (unlike its neighbours) so the widget test can render the
/// `suggested_qty == null` case without standing up a provider container.
class TradeRow extends StatelessWidget {
const TradeRow({required this.trade, super.key});
final RebalanceTrade trade;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isBuy = trade.action == 'buy';
final noQty = trade.suggestedQty == null;
final details = [
if (trade.lot != null) 'лот ${trade.lot}',
if (trade.price != null)
'цена ${MoneyText.format(trade.price!, trade.priceCurrency ?? 'RUB')}',
].join(' · ');
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
onTap: trade.instrumentId == null
? null
: () => context.push('/portfolio/instrument/${trade.instrumentId}'),
leading: Icon(
isBuy ? Icons.add_circle_outline : Icons.remove_circle_outline,
color: isBuy
? Theme.of(context).colorScheme.primary
: theme.colorScheme.error,
),
title: Row(
children: [
Flexible(child: Text(trade.title, overflow: TextOverflow.ellipsis)),
const SizedBox(width: 8),
if (trade.blockedByCash) const _BlockedByCashChip(),
],
),
subtitle: Text(
// a missing price is a reason, not a zero quantity
noQty
? 'Количество не рассчитано: нет цены инструмента'
: '${isBuy ? 'Купить' : 'Продать'} ${formatQty(trade.suggestedQty!)} шт.'
'${details.isEmpty ? '' : ' · $details'}',
style: theme.textTheme.bodySmall,
),
trailing: noQty || trade.amountRub == null
? const Text('—')
: MoneyText(
trade.amountRub!,
currency: 'RUB',
style: theme.textTheme.titleSmall,
),
);
}
}
class _BlockedByCashChip extends StatelessWidget {
const _BlockedByCashChip();
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: 'Количество урезано: на счетах не хватает денег на полный объём',
child: Chip(
avatar: const Icon(Icons.account_balance_wallet_outlined, size: 16),
label: const Text('не хватает денег'),
backgroundColor: scheme.errorContainer,
visualDensity: VisualDensity.compact,
),
);
}
}