Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income, /rebalance, /tax, аналитика-хаб с benchmarks_card) — по docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для загрузки отчёта.
344 lines
12 KiB
Dart
344 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: (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,
|
|
),
|
|
);
|
|
}
|
|
}
|