style(app): остальные экраны под новый визуальный язык и форматирование

Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
This commit is contained in:
Dmitry
2026-09-19 22:14:27 +03:00
parent 62d36aa3e8
commit 322c60a359
55 changed files with 2158 additions and 1080 deletions
@@ -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 {}),
+3 -1
View File
@@ -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;
}
+40 -21
View File
@@ -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,
),
);
}
}
+19 -11
View File
@@ -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.
+50 -11
View File
@@ -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;
+85 -31
View File
@@ -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(
+5 -1
View File
@@ -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();