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 createState() => _TargetsTabState(); } class _TargetsTabState extends ConsumerState { /// 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? _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 _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 []; 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 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 []; 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( 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), ), ], ), ); } }