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 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, ), ); } }