style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
This commit is contained in:
@@ -34,31 +34,39 @@ class 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,
|
||||
);
|
||||
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,
|
||||
};
|
||||
'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']),
|
||||
);
|
||||
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});
|
||||
const TargetSet({
|
||||
required this.dimension,
|
||||
this.targets = const [],
|
||||
this.weightsSum,
|
||||
});
|
||||
|
||||
final String dimension;
|
||||
final List<TargetWeight> targets;
|
||||
@@ -72,18 +80,19 @@ class TargetSet {
|
||||
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');
|
||||
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()],
|
||||
};
|
||||
'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']),
|
||||
);
|
||||
dimension: asString(json['dimension']) ?? 'asset_class',
|
||||
targets: asObjects(json['targets']).map(TargetWeight.fromJson).toList(),
|
||||
weightsSum: asString(json['weights_sum']),
|
||||
);
|
||||
}
|
||||
|
||||
class RebalanceTrade {
|
||||
@@ -119,20 +128,21 @@ class RebalanceTrade {
|
||||
/// tell an underweight recommendation from a wrong one.
|
||||
final bool blockedByCash;
|
||||
|
||||
String get title => ticker ?? name ?? (instrumentId == null ? '—' : '#$instrumentId');
|
||||
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']),
|
||||
);
|
||||
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 {
|
||||
@@ -161,15 +171,15 @@ class RebalanceBucket {
|
||||
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(),
|
||||
);
|
||||
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 {
|
||||
@@ -191,17 +201,18 @@ class RebalancePlan {
|
||||
final List<RebalanceBucket> buckets;
|
||||
final List<String> warnings;
|
||||
|
||||
bool get everythingWithinBand => buckets.isNotEmpty && buckets.every((b) => b.withinBand);
|
||||
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']),
|
||||
);
|
||||
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 {
|
||||
@@ -217,12 +228,18 @@ class RebalanceApi {
|
||||
/// not — revisit once the route is in the spec.
|
||||
/// 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<TargetSet>> getTargets(int portfolioId, {String dimension = 'asset_class'}) async {
|
||||
Future<Cached<TargetSet>> getTargets(
|
||||
int portfolioId, {
|
||||
String dimension = 'asset_class',
|
||||
}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'$_base/$portfolioId/targets',
|
||||
queryParameters: {'dimension': dimension},
|
||||
);
|
||||
return Cached(TargetSet.fromJson(r.data ?? const {}), fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
return Cached(
|
||||
TargetSet.fromJson(r.data ?? const {}),
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Full replacement of one dimension — partial updates are not supported by the contract.
|
||||
@@ -241,7 +258,10 @@ class RebalanceApi {
|
||||
}) async {
|
||||
final r = await _dio.get<Map<String, dynamic>>(
|
||||
'$_base/$portfolioId/rebalance',
|
||||
queryParameters: {'dimension': dimension, 'cash_available': ?cashAvailable},
|
||||
queryParameters: {
|
||||
'dimension': dimension,
|
||||
'cash_available': ?cashAvailable,
|
||||
},
|
||||
);
|
||||
return Cached(
|
||||
RebalancePlan.fromJson(r.data ?? const {}),
|
||||
|
||||
@@ -10,5 +10,7 @@ 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;
|
||||
return dimension == 'asset_class'
|
||||
? (assetClassLabels[bucket] ?? bucket)
|
||||
: bucket;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,9 @@ class RebalancePlanTab extends ConsumerWidget {
|
||||
child: const ListTile(
|
||||
leading: Icon(Icons.check_circle_outline),
|
||||
title: Text('Все группы внутри коридора'),
|
||||
subtitle: Text('Действий не требуется — отклонения меньше заданного допуска.'),
|
||||
subtitle: Text(
|
||||
'Действий не требуется — отклонения меньше заданного допуска.',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -104,7 +106,9 @@ class _Header extends ConsumerWidget {
|
||||
: MoneyText(plan.totalValueRub!, currency: 'RUB'),
|
||||
),
|
||||
_Figure(
|
||||
label: whatIf == null ? 'Доступно денег' : 'Доступно денег (what-if)',
|
||||
label: whatIf == null
|
||||
? 'Доступно денег'
|
||||
: 'Доступно денег (what-if)',
|
||||
child: plan.cashAvailableRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(plan.cashAvailableRub!, currency: 'RUB'),
|
||||
@@ -149,8 +153,9 @@ class _WhatIfCashField extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> {
|
||||
late final TextEditingController _controller =
|
||||
TextEditingController(text: widget.current ?? '');
|
||||
late final TextEditingController _controller = TextEditingController(
|
||||
text: widget.current ?? '',
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -159,7 +164,10 @@ class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> {
|
||||
}
|
||||
|
||||
void _apply() {
|
||||
final text = _controller.text.trim().replaceAll(',', '.').replaceAll(' ', '');
|
||||
final text = _controller.text
|
||||
.trim()
|
||||
.replaceAll(',', '.')
|
||||
.replaceAll(' ', '');
|
||||
ref.read(whatIfCashProvider.notifier).state = text.isEmpty ? null : text;
|
||||
}
|
||||
|
||||
@@ -228,30 +236,35 @@ class _BucketCard extends StatelessWidget {
|
||||
final theme = Theme.of(context);
|
||||
return SectionCard(
|
||||
title: bucketLabelForKey(dimension, bucket.bucket),
|
||||
subtitle: 'сейчас ${formatShareAsPercent(bucket.currentWeight)} · '
|
||||
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)),
|
||||
)),
|
||||
? 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)]),
|
||||
? Text(
|
||||
'Сервер не предложил сделок по этой группе: '
|
||||
'вероятно, у подходящих бумаг нет цены — см. предупреждения выше.',
|
||||
style: theme.textTheme.bodySmall,
|
||||
)
|
||||
: Column(
|
||||
children: [for (final t in bucket.trades) TradeRow(trade: t)],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -301,7 +314,9 @@ class TradeRow extends StatelessWidget {
|
||||
: () => 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,
|
||||
color: isBuy
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: theme.colorScheme.error,
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
@@ -315,12 +330,16 @@ class TradeRow extends StatelessWidget {
|
||||
noQty
|
||||
? 'Количество не рассчитано: нет цены инструмента'
|
||||
: '${isBuy ? 'Купить' : 'Продать'} ${formatQty(trade.suggestedQty!)} шт.'
|
||||
'${details.isEmpty ? '' : ' · $details'}',
|
||||
'${details.isEmpty ? '' : ' · $details'}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
trailing: noQty || trade.amountRub == null
|
||||
? const Text('—')
|
||||
: MoneyText(trade.amountRub!, currency: 'RUB', style: theme.textTheme.titleSmall),
|
||||
: MoneyText(
|
||||
trade.amountRub!,
|
||||
currency: 'RUB',
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import '../../core/cache/cached.dart';
|
||||
import '../portfolio/providers.dart' show scopeProvider, scopesProvider;
|
||||
import 'data/rebalance_api.dart';
|
||||
|
||||
final rebalanceApiProvider =
|
||||
Provider<RebalanceApi>((ref) => RebalanceApi(ref.watch(apiProvider).dio));
|
||||
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
|
||||
@@ -17,7 +18,9 @@ class PortfolioRef {
|
||||
final String name;
|
||||
}
|
||||
|
||||
final portfoliosProvider = FutureProvider.autoDispose<List<PortfolioRef>>((ref) async {
|
||||
final portfoliosProvider = FutureProvider.autoDispose<List<PortfolioRef>>((
|
||||
ref,
|
||||
) async {
|
||||
final scopes = await ref.watch(scopesProvider.future);
|
||||
return [
|
||||
for (final s in scopes)
|
||||
@@ -41,20 +44,25 @@ final whatIfCashProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
/// See `docs/ai/offline-cache.md`. No portfolio selected yet is a live, empty answer — not a
|
||||
/// stale one — so it is wrapped with `fetchedAt: null` rather than left unwrapped.
|
||||
final targetsProvider = FutureProvider.autoDispose<Cached<TargetSet>>((ref) async {
|
||||
final targetsProvider = FutureProvider.autoDispose<Cached<TargetSet>>((
|
||||
ref,
|
||||
) async {
|
||||
final id = ref.watch(selectedPortfolioProvider);
|
||||
final dimension = ref.watch(targetDimensionProvider);
|
||||
if (id == null) return Cached(TargetSet(dimension: dimension));
|
||||
return ref.watch(rebalanceApiProvider).getTargets(id, dimension: dimension);
|
||||
});
|
||||
|
||||
final rebalancePlanProvider = FutureProvider.autoDispose<Cached<RebalancePlan?>>((ref) async {
|
||||
final id = ref.watch(selectedPortfolioProvider);
|
||||
final dimension = ref.watch(targetDimensionProvider);
|
||||
final cash = ref.watch(whatIfCashProvider);
|
||||
if (id == null) return const Cached(null);
|
||||
return ref.watch(rebalanceApiProvider).plan(id, dimension: dimension, cashAvailable: cash);
|
||||
});
|
||||
final rebalancePlanProvider =
|
||||
FutureProvider.autoDispose<Cached<RebalancePlan?>>((ref) async {
|
||||
final id = ref.watch(selectedPortfolioProvider);
|
||||
final dimension = ref.watch(targetDimensionProvider);
|
||||
final cash = ref.watch(whatIfCashProvider);
|
||||
if (id == null) return const Cached(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.
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/cache/cached.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/stale_banner.dart';
|
||||
import '../portfolio/labels.dart' show dimensionLabels;
|
||||
import '../portfolios/providers.dart' show portfolioListProvider;
|
||||
import 'data/rebalance_api.dart';
|
||||
import 'plan_tab.dart';
|
||||
import 'providers.dart';
|
||||
@@ -45,7 +47,10 @@ class RebalancePage extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
bottom: const TabBar(
|
||||
tabs: [Tab(text: 'Целевые веса'), Tab(text: 'Рекомендации')],
|
||||
tabs: [
|
||||
Tab(text: 'Целевые веса'),
|
||||
Tab(text: 'Рекомендации'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
@@ -61,21 +66,48 @@ class RebalancePage extends ConsumerWidget {
|
||||
onRetry: () => ref.invalidate(portfoliosProvider),
|
||||
data: (list) {
|
||||
if (list.isEmpty) {
|
||||
return const EmptyState(
|
||||
icon: Icons.pie_chart_outline,
|
||||
message: 'Портфелей пока нет.\n'
|
||||
'Ребалансировка считается по портфелю: заведите его в настройках счетов.',
|
||||
// A portfolio can exist yet be absent here: this list comes from the
|
||||
// analytics scopes, which only cover accounts that have ledger events.
|
||||
final created =
|
||||
ref
|
||||
.watch(portfolioListProvider)
|
||||
.valueOrNull
|
||||
?.isNotEmpty ??
|
||||
false;
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
EmptyState(
|
||||
icon: Icons.pie_chart_outline,
|
||||
message: created
|
||||
? 'Портфели есть, но в них нет счетов с бумагами.\n'
|
||||
'Добавьте брокерские счета: у счетов ZenMoney нет сделок, '
|
||||
'считать по ним нечего.'
|
||||
: 'Портфелей пока нет.\n'
|
||||
'Ребалансировка считается по портфелю — набору счетов.',
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.go('/portfolios'),
|
||||
icon: Icon(created ? Icons.edit_outlined : Icons.add),
|
||||
label: Text(
|
||||
created ? 'Изменить портфели' : 'Создать портфель',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
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;
|
||||
ref.read(selectedPortfolioProvider.notifier).state =
|
||||
list.first.id;
|
||||
});
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return const TabBarView(children: [TargetsTab(), RebalancePlanTab()]);
|
||||
return const TabBarView(
|
||||
children: [TargetsTab(), RebalancePlanTab()],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -91,16 +123,23 @@ class _PortfolioSelector extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final portfolios = ref.watch(portfoliosProvider).valueOrNull ?? const <PortfolioRef>[];
|
||||
final portfolios =
|
||||
ref.watch(portfoliosProvider).valueOrNull ?? const <PortfolioRef>[];
|
||||
final selected = ref.watch(selectedPortfolioProvider);
|
||||
if (portfolios.length < 2 || selected == null) return const SizedBox.shrink();
|
||||
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,
|
||||
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)),
|
||||
DropdownMenuItem(
|
||||
value: p.id,
|
||||
child: Text(p.name, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) ref.read(selectedPortfolioProvider.notifier).state = v;
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 '../../core/widgets/help_tip.dart';
|
||||
import '../portfolio/labels.dart' show assetClassLabels;
|
||||
import 'data/rebalance_api.dart';
|
||||
import 'labels.dart';
|
||||
@@ -38,7 +39,8 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
|
||||
_dirty = false;
|
||||
}
|
||||
|
||||
Decimal get _sum => sumDecimals((_draft ?? const []).map((t) => t.targetWeight));
|
||||
Decimal get _sum =>
|
||||
sumDecimals((_draft ?? const []).map((t) => t.targetWeight));
|
||||
|
||||
bool get _sumIsValid => (_sum - Decimal.one).abs() <= Decimal.parse('0.0001');
|
||||
|
||||
@@ -52,13 +54,17 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
|
||||
try {
|
||||
await ref
|
||||
.read(rebalanceApiProvider)
|
||||
.putTargets(portfolioId, TargetSet(dimension: dimension, targets: draft));
|
||||
.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('Целевые веса сохранены')));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Целевые веса сохранены')));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
@@ -84,11 +90,16 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 88),
|
||||
children: [
|
||||
_SumBanner(sum: _sum, valid: _sumIsValid, serverSum: set.weightsSum),
|
||||
_SumBanner(
|
||||
sum: _sum,
|
||||
valid: _sumIsValid,
|
||||
serverSum: set.weightsSum,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SectionCard(
|
||||
title: 'Целевые веса',
|
||||
subtitle: 'Вес — доля портфеля; допуск (band) — ширина коридора, '
|
||||
subtitle:
|
||||
'Вес — доля портфеля; допуск (band) — ширина коридора, '
|
||||
'внутри которого сделки не предлагаются.',
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -118,7 +129,11 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
|
||||
onPressed: () => setState(() {
|
||||
_draft = [
|
||||
...draft,
|
||||
const TargetWeight(bucket: '', targetWeight: '0', band: '0.05'),
|
||||
const TargetWeight(
|
||||
bucket: '',
|
||||
targetWeight: '0',
|
||||
band: '0.05',
|
||||
),
|
||||
];
|
||||
_dirty = true;
|
||||
}),
|
||||
@@ -133,12 +148,18 @@ class _TargetsTabState extends ConsumerState<TargetsTab> {
|
||||
Row(
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: _saving || !_sumIsValid || draft.any((t) => t.bucket.isEmpty)
|
||||
onPressed:
|
||||
_saving ||
|
||||
!_sumIsValid ||
|
||||
draft.any((t) => t.bucket.isEmpty)
|
||||
? null
|
||||
: _save,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save_outlined),
|
||||
label: const Text('Сохранить'),
|
||||
),
|
||||
@@ -192,7 +213,7 @@ class _SumBanner extends StatelessWidget {
|
||||
valid
|
||||
? 'Набор можно сохранить.'
|
||||
: 'Нужно ровно 100 %. Разница: '
|
||||
'${formatShareAsPercent((sum - Decimal.one).toString(), signed: true)}',
|
||||
'${formatShareAsPercent((sum - Decimal.one).toString(), signed: true)}',
|
||||
),
|
||||
trailing: serverSum == null
|
||||
? null
|
||||
@@ -224,12 +245,15 @@ class _TargetRow extends StatefulWidget {
|
||||
}
|
||||
|
||||
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);
|
||||
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() {
|
||||
@@ -257,20 +281,32 @@ class _TargetRowState extends State<_TargetRow> {
|
||||
child: knownBuckets.isEmpty
|
||||
? TextField(
|
||||
controller: _bucket,
|
||||
decoration: const InputDecoration(labelText: 'Группа', isDense: true),
|
||||
onChanged: (v) => widget.onChanged(widget.target.copyWith(bucket: v)),
|
||||
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,
|
||||
initialValue: knownBuckets.contains(widget.target.bucket)
|
||||
? widget.target.bucket
|
||||
: null,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(labelText: 'Группа', isDense: true),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Группа',
|
||||
isDense: true,
|
||||
),
|
||||
items: [
|
||||
for (final b in knownBuckets)
|
||||
DropdownMenuItem(value: b, child: Text(bucketLabelForKey('asset_class', b))),
|
||||
DropdownMenuItem(
|
||||
value: b,
|
||||
child: Text(bucketLabelForKey('asset_class', b)),
|
||||
),
|
||||
],
|
||||
onChanged: (v) =>
|
||||
widget.onChanged(widget.target.copyWith(bucket: v ?? '')),
|
||||
onChanged: (v) => widget.onChanged(
|
||||
widget.target.copyWith(bucket: v ?? ''),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -278,11 +314,18 @@ class _TargetRowState extends State<_TargetRow> {
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: _weight,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(labelText: 'Вес, %', isDense: true),
|
||||
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'));
|
||||
widget.onChanged(
|
||||
widget.target.copyWith(targetWeight: share ?? '0'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -291,10 +334,21 @@ class _TargetRowState extends State<_TargetRow> {
|
||||
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))),
|
||||
keyboardType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Допуск, %',
|
||||
isDense: true,
|
||||
suffixIcon: HelpTip(
|
||||
'Допуск (band) — ширина коридора вокруг целевого веса: пока фактическая доля '
|
||||
'внутри него, сделки не предлагаются. ±5 % значит «не трогать, пока доля в '
|
||||
'пределах 5 процентных пунктов от цели».',
|
||||
),
|
||||
),
|
||||
onChanged: (v) => widget.onChanged(
|
||||
widget.target.copyWith(band: percentTextToShare(v)),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
|
||||
@@ -26,7 +26,11 @@ String? percentTextToShare(String text) {
|
||||
|
||||
/// `"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}) {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user