Files
fin-tracker/app/lib/features/rebalance/targets_tab.dart
T
Dmitry b69bb4a0c9 feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
2026-09-19 10:44:38 +03:00

309 lines
12 KiB
Dart

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