feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков

Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
This commit is contained in:
Dmitry
2026-09-19 10:44:38 +03:00
parent 15f5812ea4
commit b69bb4a0c9
52 changed files with 7404 additions and 13 deletions
@@ -0,0 +1,245 @@
/// Hand-written client for `/api/v1/portfolios/{id}/targets` and `/rebalance`.
///
/// **Temporary.** These routes are not in `openapi/openapi.json` yet, so
/// `app/packages/api_client` has no generated methods or models for them. Everything here
/// follows `docs/ai/phase4-contract.md` §2 literally and is meant to be **replaced by the
/// generated client** once the routes land in the spec and `just gen-client` runs.
///
/// Raw Dio comes from `ref.read(apiProvider).dio`: base URL, bearer header and the one-shot
/// refresh on 401 are already wired there.
library;
import 'package:decimal/decimal.dart';
import 'package:dio/dio.dart';
import '../../../core/utils/json.dart';
/// The dimensions targets can be set along, as wire strings (`AssetClass` is never exposed
/// as an enum — `index` cannot be a Dart enum member).
const targetDimensions = ['asset_class', 'sector', 'country', 'currency'];
class TargetWeight {
const TargetWeight({
required this.bucket,
required this.targetWeight,
this.band,
this.note,
});
final String bucket;
/// A share, not a percent: `"0.60"` is 60 %.
final String targetWeight;
final String? band;
final String? note;
TargetWeight copyWith({String? bucket, String? targetWeight, String? band, String? note}) =>
TargetWeight(
bucket: bucket ?? this.bucket,
targetWeight: targetWeight ?? this.targetWeight,
band: band ?? this.band,
note: note ?? this.note,
);
Map<String, dynamic> toJson() => {
'bucket': bucket,
'target_weight': targetWeight,
'band': ?band,
'note': ?note,
};
static TargetWeight fromJson(Map<String, dynamic> json) => TargetWeight(
bucket: asString(json['bucket']) ?? '',
targetWeight: asString(json['target_weight']) ?? '0',
band: asString(json['band']),
note: asString(json['note']),
);
}
class TargetSet {
const TargetSet({required this.dimension, this.targets = const [], this.weightsSum});
final String dimension;
final List<TargetWeight> targets;
/// What the server computed. The client computes its own sum too — the user must see the
/// problem before pressing Save, not after the 422 comes back.
final String? weightsSum;
/// Exact sum of the weights as typed. `Decimal`, never `double`: 0.1 + 0.2 in binary
/// floating point would make a perfectly valid set look broken.
Decimal get localSum => sumDecimals(targets.map((t) => t.targetWeight));
/// The contract's tolerance: the sum must be 1 within 0.0001.
bool get sumIsValid => (localSum - Decimal.one).abs() <= Decimal.parse('0.0001');
Map<String, dynamic> toJson() => {
'dimension': dimension,
'targets': [for (final t in targets) t.toJson()],
};
static TargetSet fromJson(Map<String, dynamic> json) => TargetSet(
dimension: asString(json['dimension']) ?? 'asset_class',
targets: asObjects(json['targets']).map(TargetWeight.fromJson).toList(),
weightsSum: asString(json['weights_sum']),
);
}
class RebalanceTrade {
const RebalanceTrade({
required this.action,
required this.blockedByCash,
this.instrumentId,
this.ticker,
this.name,
this.suggestedQty,
this.lot,
this.price,
this.priceCurrency,
this.amountRub,
});
final int? instrumentId;
final String? ticker;
final String? name;
/// `buy | sell`.
final String action;
/// Whole lots, always inside the available cash. **Null means there is no price** — the
/// screen shows an em dash and the reason, never 0.
final String? suggestedQty;
final int? lot;
final String? price;
final String? priceCurrency;
final String? amountRub;
/// The quantity was cut down because the cash ran out. Without this flag a user cannot
/// tell an underweight recommendation from a wrong one.
final bool blockedByCash;
String get title => ticker ?? name ?? (instrumentId == null ? '' : '#$instrumentId');
static RebalanceTrade fromJson(Map<String, dynamic> json) => RebalanceTrade(
instrumentId: asInt(json['instrument_id']),
ticker: asString(json['ticker']),
name: asString(json['name']),
action: asString(json['action']) ?? 'buy',
suggestedQty: asString(json['suggested_qty']),
lot: asInt(json['lot']),
price: asString(json['price']),
priceCurrency: asString(json['price_currency']),
amountRub: asString(json['amount_rub']),
blockedByCash: asBool(json['blocked_by_cash']),
);
}
class RebalanceBucket {
const RebalanceBucket({
required this.bucket,
required this.withinBand,
this.currentValueRub,
this.currentWeight,
this.targetWeight,
this.drift,
this.deltaValueRub,
this.trades = const [],
});
final String bucket;
final String? currentValueRub;
final String? currentWeight;
final String? targetWeight;
/// `current - target`, in shares.
final String? drift;
/// `|drift| <= band` — no action needed, and saying so is the point.
final bool withinBand;
final String? deltaValueRub;
final List<RebalanceTrade> trades;
static RebalanceBucket fromJson(Map<String, dynamic> json) => RebalanceBucket(
bucket: asString(json['bucket']) ?? '',
currentValueRub: asString(json['current_value_rub']),
currentWeight: asString(json['current_weight']),
targetWeight: asString(json['target_weight']),
drift: asString(json['drift']),
withinBand: asBool(json['within_band']),
deltaValueRub: asString(json['delta_value_rub']),
trades: asObjects(json['trades']).map(RebalanceTrade.fromJson).toList(),
);
}
class RebalancePlan {
const RebalancePlan({
required this.portfolioId,
required this.dimension,
this.asOf,
this.totalValueRub,
this.cashAvailableRub,
this.buckets = const [],
this.warnings = const [],
});
final int portfolioId;
final String dimension;
final DateTime? asOf;
final String? totalValueRub;
final String? cashAvailableRub;
final List<RebalanceBucket> buckets;
final List<String> warnings;
bool get everythingWithinBand => buckets.isNotEmpty && buckets.every((b) => b.withinBand);
static RebalancePlan fromJson(Map<String, dynamic> json) => RebalancePlan(
portfolioId: asInt(json['portfolio_id']) ?? 0,
dimension: asString(json['dimension']) ?? 'asset_class',
asOf: asDate(json['as_of']),
totalValueRub: asString(json['total_value_rub']),
cashAvailableRub: asString(json['cash_available_rub']),
buckets: asObjects(json['buckets']).map(RebalanceBucket.fromJson).toList(),
warnings: asStrings(json['warnings']),
);
}
class RebalanceApi {
const RebalanceApi(this._dio);
final Dio _dio;
static const _base = '/api/v1/portfolios';
/// The contract does not spell out a query parameter for `GET .../targets`, but `PUT`
/// is per-dimension, so the set has to be addressable per-dimension as well. Sending
/// `dimension` is harmless for a server that ignores it and necessary for one that does
/// not — revisit once the route is in the spec.
Future<TargetSet> getTargets(int portfolioId, {String dimension = 'asset_class'}) async {
final r = await _dio.get<Map<String, dynamic>>(
'$_base/$portfolioId/targets',
queryParameters: {'dimension': dimension},
);
return TargetSet.fromJson(r.data ?? const {});
}
/// Full replacement of one dimension — partial updates are not supported by the contract.
Future<TargetSet> putTargets(int portfolioId, TargetSet set) async {
final r = await _dio.put<Map<String, dynamic>>(
'$_base/$portfolioId/targets',
data: set.toJson(),
);
return TargetSet.fromJson(r.data ?? const {});
}
Future<RebalancePlan> plan(
int portfolioId, {
String dimension = 'asset_class',
String? cashAvailable,
}) async {
final r = await _dio.get<Map<String, dynamic>>(
'$_base/$portfolioId/rebalance',
queryParameters: {'dimension': dimension, 'cash_available': ?cashAvailable},
);
return RebalancePlan.fromJson(r.data ?? const {});
}
}
+14
View File
@@ -0,0 +1,14 @@
import '../portfolio/labels.dart' show assetClassLabels;
/// Bucket labels keyed by the **wire** dimension string.
///
/// `features/portfolio/labels.dart` already has `bucketLabel`, but it takes the generated
/// `AllocationDimension` enum, and the rebalance routes are not in the spec yet — this
/// layer only has the plain string. Once `just gen-client` produces the enum, this helper
/// should give way to the existing one.
String bucketLabelForKey(String dimension, String bucket) {
if (bucket == 'cash') return 'Денежные средства';
if (bucket == 'unknown') return 'Не указано';
if (bucket.isEmpty) return '';
return dimension == 'asset_class' ? (assetClassLabels[bucket] ?? bucket) : bucket;
}
+343
View File
@@ -0,0 +1,343 @@
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,
),
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../portfolio/providers.dart' show scopeProvider, scopesProvider;
import 'data/rebalance_api.dart';
final rebalanceApiProvider =
Provider<RebalanceApi>((ref) => RebalanceApi(ref.watch(apiProvider).dio));
/// A portfolio the user can rebalance, derived from the scope list the analytics API
/// already publishes (`portfolio:<id>`): there is no separate portfolios endpoint, and
/// inventing one would be a contract of its own.
class PortfolioRef {
const PortfolioRef(this.id, this.name);
final int id;
final String name;
}
final portfoliosProvider = FutureProvider.autoDispose<List<PortfolioRef>>((ref) async {
final scopes = await ref.watch(scopesProvider.future);
return [
for (final s in scopes)
if (s.scope.startsWith('portfolio:'))
PortfolioRef(int.parse(s.scope.substring('portfolio:'.length)), s.name),
];
});
/// The portfolio the rebalance screen is working on. Null until the list loads; seeded from
/// the app-wide scope when that scope already names a portfolio.
final selectedPortfolioProvider = StateProvider<int?>((ref) {
final scope = ref.watch(scopeProvider);
if (!scope.startsWith('portfolio:')) return null;
return int.tryParse(scope.substring('portfolio:'.length));
});
final targetDimensionProvider = StateProvider<String>((ref) => 'asset_class');
/// What-if cash for the recommendations, as a decimal string. Null = use the real balance.
final whatIfCashProvider = StateProvider<String?>((ref) => null);
final targetsProvider = FutureProvider.autoDispose<TargetSet>((ref) async {
final id = ref.watch(selectedPortfolioProvider);
final dimension = ref.watch(targetDimensionProvider);
if (id == null) return TargetSet(dimension: dimension);
return ref.watch(rebalanceApiProvider).getTargets(id, dimension: dimension);
});
final rebalancePlanProvider = FutureProvider.autoDispose<RebalancePlan?>((ref) async {
final id = ref.watch(selectedPortfolioProvider);
final dimension = ref.watch(targetDimensionProvider);
final cash = ref.watch(whatIfCashProvider);
if (id == null) return null;
return ref.watch(rebalanceApiProvider).plan(id, dimension: dimension, cashAvailable: cash);
});
/// After a successful PUT the numbers are recomputed on the server, so both sides of the
/// screen are refetched rather than patched in place.
void invalidateRebalanceProviders(WidgetRef ref) {
ref.invalidate(targetsProvider);
ref.invalidate(rebalancePlanProvider);
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../portfolio/labels.dart' show dimensionLabels;
import 'data/rebalance_api.dart';
import 'plan_tab.dart';
import 'providers.dart';
import 'targets_tab.dart';
/// Ребалансировка: the target weights on one tab, the resulting recommendations on the
/// other. Both are per portfolio and per dimension, so the pickers live in the app bar and
/// drive both tabs at once.
class RebalancePage extends ConsumerWidget {
const RebalancePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final portfolios = ref.watch(portfoliosProvider);
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Ребалансировка'),
actions: [
const _PortfolioSelector(),
const _DimensionSelector(),
IconButton(
tooltip: 'Обновить',
icon: const Icon(Icons.refresh),
onPressed: () => invalidateRebalanceProviders(ref),
),
],
bottom: const TabBar(
tabs: [Tab(text: 'Целевые веса'), Tab(text: 'Рекомендации')],
),
),
body: AsyncValueView(
value: portfolios,
onRetry: () => ref.invalidate(portfoliosProvider),
data: (list) {
if (list.isEmpty) {
return const EmptyState(
icon: Icons.pie_chart_outline,
message: 'Портфелей пока нет.\n'
'Ребалансировка считается по портфелю: заведите его в настройках счетов.',
);
}
final selected = ref.watch(selectedPortfolioProvider);
if (selected == null || !list.any((p) => p.id == selected)) {
// pick the first portfolio once, after the list is known
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(selectedPortfolioProvider.notifier).state = list.first.id;
});
return const Center(child: CircularProgressIndicator());
}
return const TabBarView(children: [TargetsTab(), RebalancePlanTab()]);
},
),
),
);
}
}
class _PortfolioSelector extends ConsumerWidget {
const _PortfolioSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final portfolios = ref.watch(portfoliosProvider).valueOrNull ?? const <PortfolioRef>[];
final selected = ref.watch(selectedPortfolioProvider);
if (portfolios.length < 2 || selected == null) return const SizedBox.shrink();
return DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: portfolios.any((p) => p.id == selected) ? selected : portfolios.first.id,
borderRadius: BorderRadius.circular(8),
items: [
for (final p in portfolios)
DropdownMenuItem(value: p.id, child: Text(p.name, overflow: TextOverflow.ellipsis)),
],
onChanged: (v) {
if (v != null) ref.read(selectedPortfolioProvider.notifier).state = v;
},
),
);
}
}
class _DimensionSelector extends ConsumerWidget {
const _DimensionSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final current = ref.watch(targetDimensionProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: current,
borderRadius: BorderRadius.circular(8),
items: [
for (final d in targetDimensions)
DropdownMenuItem(value: d, child: Text(dimensionLabels[d] ?? d)),
],
onChanged: (v) {
if (v != null) ref.read(targetDimensionProvider.notifier).state = v;
},
),
),
);
}
}
+308
View File
@@ -0,0 +1,308 @@
import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/utils/json.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/section_card.dart';
import '../portfolio/labels.dart' show assetClassLabels;
import 'data/rebalance_api.dart';
import 'labels.dart';
import 'providers.dart';
import 'weights.dart';
/// Целевые веса: the editable target set for one dimension.
///
/// The sum of the weights is shown permanently and Save is blocked whenever it differs from
/// 100 % by more than 0,01 pp. The server answers 422 in that case — but a user should see
/// the problem while typing, not after a round trip.
class TargetsTab extends ConsumerStatefulWidget {
const TargetsTab({super.key});
@override
ConsumerState<TargetsTab> createState() => _TargetsTabState();
}
class _TargetsTabState extends ConsumerState<TargetsTab> {
/// Local draft. Seeded from the server set and reseeded whenever the portfolio or the
/// dimension changes — editing one dimension must never leak into another.
List<TargetWeight>? _draft;
String? _seededFor;
bool _saving = false;
bool _dirty = false;
void _seed(TargetSet set, String key) {
if (_seededFor == key) return;
_seededFor = key;
_draft = [...set.targets];
_dirty = false;
}
Decimal get _sum => sumDecimals((_draft ?? const []).map((t) => t.targetWeight));
bool get _sumIsValid => (_sum - Decimal.one).abs() <= Decimal.parse('0.0001');
Future<void> _save() async {
final portfolioId = ref.read(selectedPortfolioProvider);
final dimension = ref.read(targetDimensionProvider);
final draft = _draft;
if (portfolioId == null || draft == null) return;
setState(() => _saving = true);
try {
await ref
.read(rebalanceApiProvider)
.putTargets(portfolioId, TargetSet(dimension: dimension, targets: draft));
if (!mounted) return;
_dirty = false;
_seededFor = null; // refetch reseeds the draft from the saved set
invalidateRebalanceProviders(ref);
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Целевые веса сохранены')));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final portfolioId = ref.watch(selectedPortfolioProvider);
final dimension = ref.watch(targetDimensionProvider);
final targets = ref.watch(targetsProvider);
return AsyncValueView(
value: targets,
onRetry: () => ref.invalidate(targetsProvider),
data: (set) {
_seed(set, '$portfolioId/$dimension');
final draft = _draft ?? const <TargetWeight>[];
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88),
children: [
_SumBanner(sum: _sum, valid: _sumIsValid, serverSum: set.weightsSum),
const SizedBox(height: 12),
SectionCard(
title: 'Целевые веса',
subtitle: 'Вес — доля портфеля; допуск (band) — ширина коридора, '
'внутри которого сделки не предлагаются.',
child: Column(
children: [
for (var i = 0; i < draft.length; i++)
_TargetRow(
key: ValueKey('${_seededFor}_$i'),
dimension: dimension,
target: draft[i],
onChanged: (t) => setState(() {
_draft![i] = t;
_dirty = true;
}),
onRemove: () => setState(() {
_draft!.removeAt(i);
_dirty = true;
}),
),
if (draft.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Text('Целевые веса ещё не заданы.'),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => setState(() {
_draft = [
...draft,
const TargetWeight(bucket: '', targetWeight: '0', band: '0.05'),
];
_dirty = true;
}),
icon: const Icon(Icons.add),
label: const Text('Добавить группу'),
),
),
],
),
),
const SizedBox(height: 16),
Row(
children: [
FilledButton.icon(
onPressed: _saving || !_sumIsValid || draft.any((t) => t.bucket.isEmpty)
? null
: _save,
icon: _saving
? const SizedBox(
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.save_outlined),
label: const Text('Сохранить'),
),
const SizedBox(width: 12),
if (_dirty)
TextButton(
onPressed: () => setState(() {
_seededFor = null;
_seed(set, '$portfolioId/$dimension');
}),
child: const Text('Отменить изменения'),
),
],
),
const SizedBox(height: 8),
if (!_sumIsValid)
Text(
'Сохранение заблокировано: сумма весов должна быть ровно 100 %. '
'Сервер не нормализует веса — сумма 90 % означает, что 10 % портфеля '
'не отнесены ни к одной группе, а не что доли надо растянуть.',
style: Theme.of(context).textTheme.bodySmall,
)
else if (draft.any((t) => t.bucket.isEmpty))
Text(
'У одной из групп пустое имя — сохранить нельзя.',
style: Theme.of(context).textTheme.bodySmall,
),
],
);
},
);
}
}
class _SumBanner extends StatelessWidget {
const _SumBanner({required this.sum, required this.valid, this.serverSum});
final Decimal sum;
final bool valid;
final String? serverSum;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Card(
color: valid ? scheme.secondaryContainer : scheme.errorContainer,
child: ListTile(
leading: Icon(valid ? Icons.check_circle_outline : Icons.error_outline),
title: Text('Сумма весов: ${formatShareAsPercent(sum.toString())}'),
subtitle: Text(
valid
? 'Набор можно сохранить.'
: 'Нужно ровно 100 %. Разница: '
'${formatShareAsPercent((sum - Decimal.one).toString(), signed: true)}',
),
trailing: serverSum == null
? null
: Tooltip(
message: 'Последняя сумма, сохранённая на сервере',
child: Text(formatShareAsPercent(serverSum)),
),
),
);
}
}
class _TargetRow extends StatefulWidget {
const _TargetRow({
required this.dimension,
required this.target,
required this.onChanged,
required this.onRemove,
super.key,
});
final String dimension;
final TargetWeight target;
final ValueChanged<TargetWeight> onChanged;
final VoidCallback onRemove;
@override
State<_TargetRow> createState() => _TargetRowState();
}
class _TargetRowState extends State<_TargetRow> {
late final TextEditingController _weight =
TextEditingController(text: shareToPercentText(widget.target.targetWeight));
late final TextEditingController _band =
TextEditingController(text: shareToPercentText(widget.target.band));
late final TextEditingController _bucket =
TextEditingController(text: widget.target.bucket);
@override
void dispose() {
_weight.dispose();
_band.dispose();
_bucket.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// The known asset classes are offered as a list; every other dimension (sector, country,
// currency) has an open set of buckets that only the data knows, so those stay free text.
final knownBuckets = widget.dimension == 'asset_class'
? [...assetClassLabels.keys, 'cash']
: const <String>[];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: knownBuckets.isEmpty
? TextField(
controller: _bucket,
decoration: const InputDecoration(labelText: 'Группа', isDense: true),
onChanged: (v) => widget.onChanged(widget.target.copyWith(bucket: v)),
)
: DropdownButtonFormField<String>(
initialValue:
knownBuckets.contains(widget.target.bucket) ? widget.target.bucket : null,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Группа', isDense: true),
items: [
for (final b in knownBuckets)
DropdownMenuItem(value: b, child: Text(bucketLabelForKey('asset_class', b))),
],
onChanged: (v) =>
widget.onChanged(widget.target.copyWith(bucket: v ?? '')),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: TextField(
controller: _weight,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Вес, %', isDense: true),
onChanged: (v) {
final share = percentTextToShare(v);
widget.onChanged(widget.target.copyWith(targetWeight: share ?? '0'));
},
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: TextField(
controller: _band,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(labelText: 'Допуск, %', isDense: true),
onChanged: (v) =>
widget.onChanged(widget.target.copyWith(band: percentTextToShare(v))),
),
),
IconButton(
tooltip: 'Удалить группу',
onPressed: widget.onRemove,
icon: const Icon(Icons.delete_outline),
),
],
),
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:decimal/decimal.dart';
/// Weights travel as shares (`"0.60"` = 60 %) and are edited as percents. The conversion is
/// exact on both sides: `Decimal`, never `double`, so 33,33 % round-trips unchanged and a
/// set that sums to exactly 1 does not fail validation because of binary floating point.
final _hundred = Decimal.fromInt(100);
/// `"0.605"` → `"60,5"`; empty for anything unparseable.
String shareToPercentText(String? share) {
final d = share == null ? null : Decimal.tryParse(share);
if (d == null) return '';
final p = d * _hundred;
final text = p == p.truncate() ? p.truncate().toString() : p.toString();
return text.replaceAll('.', ',');
}
/// `"60,5"` → `"0.605"`; null when the text is not a number.
String? percentTextToShare(String text) {
final normalized = text.trim().replaceAll(',', '.').replaceAll(' ', '');
if (normalized.isEmpty) return null;
final d = Decimal.tryParse(normalized);
if (d == null) return null;
return (d / _hundred).toDecimal(scaleOnInfinitePrecision: 10).toString();
}
/// `"0.032"` → `"3,20 %"`, signed. Kept local to the rebalance screen because drift is a
/// share, not a return, and the portfolio helper would sign it the same way by accident.
String formatShareAsPercent(String? share, {bool signed = false, int digits = 2}) {
final d = share == null ? null : Decimal.tryParse(share);
if (d == null) return '';
final p = (d * _hundred).toDouble();
final sign = signed && p > 0 ? '+' : '';
return '$sign${p.toStringAsFixed(digits).replaceAll('.', ',')} %';
}