import 'package:decimal/decimal.dart'; import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/utils/ru_date.dart'; import '../accounts/providers.dart'; import '../pending/providers.dart' show instrumentSearchProvider; import 'labels.dart'; /// The kinds `POST /events` accepts, in the order a person looks for them. const manualEventKinds = [ EventKind.buy, EventKind.sell, EventKind.transferIn, EventKind.transferOut, EventKind.dividend, EventKind.coupon, EventKind.interest, EventKind.deposit, EventKind.withdrawal, EventKind.commission, EventKind.tax, EventKind.taxRefund, ]; bool _isTrade(EventKind k) => k == EventKind.buy || k == EventKind.sell; bool _isTransfer(EventKind k) => k == EventKind.transferIn || k == EventKind.transferOut; bool _isPayout(EventKind k) => k == EventKind.dividend || k == EventKind.coupon; /// A ledger event the broker's feeds do not carry. Amounts are typed as a person reads them /// off a statement — positive; the server derives the signs from the kind. Future showManualEventDialog(BuildContext context) => showDialog( context: context, builder: (_) => const _ManualEventDialog(), ); class _ManualEventDialog extends ConsumerStatefulWidget { const _ManualEventDialog(); @override ConsumerState<_ManualEventDialog> createState() => _ManualEventDialogState(); } class _ManualEventDialogState extends ConsumerState<_ManualEventDialog> { final _formKey = GlobalKey(); final _quantity = TextEditingController(); final _price = TextEditingController(); final _amount = TextEditingController(); final _fee = TextEditingController(); final _accrued = TextEditingController(); final _description = TextEditingController(); AccountOut? _account; EventKind _kind = EventKind.buy; DateTime _date = DateTime.now(); InstrumentOut? _instrument; @override void dispose() { for (final c in [ _quantity, _price, _amount, _fee, _accrued, _description, ]) { c.dispose(); } super.dispose(); } static String? _positive(String? v, {required bool required}) { final text = (v ?? '').trim().replaceAll(',', '.'); if (text.isEmpty) return required ? 'Обязательное поле' : null; final d = Decimal.tryParse(text); return d == null || d <= Decimal.zero ? 'Число больше нуля' : null; } static String? _text(TextEditingController c) { final t = c.text.trim().replaceAll(',', '.').replaceAll(' ', ''); return t.isEmpty ? null : t; } bool get _needsInstrument => _isTrade(_kind) || _isTransfer(_kind) || _isPayout(_kind); ManualEventCreate _build() { final trade = _isTrade(_kind); return ManualEventCreate( accountId: _account!.id, kind: _kind, tradeDate: DateTime.utc(_date.year, _date.month, _date.day), instrumentId: _needsInstrument ? _instrument!.id : null, quantity: trade || _isTransfer(_kind) ? _text(_quantity) : null, price: trade ? _text(_price) : null, amount: _isTransfer(_kind) ? null : _text(_amount), fee: trade ? _text(_fee) : null, accruedInterest: trade ? _text(_accrued) : null, description: _description.text.trim().isEmpty ? null : _description.text.trim(), ); } @override Widget build(BuildContext context) { final accounts = [ for (final a in ref.watch(accountsProvider).valueOrNull?.data ?? const []) if (a.kind == AccountKind.broker && !a.archived && !a.disabled) a, ]; final trade = _isTrade(_kind); return AlertDialog( title: const Text('Новое событие'), content: SizedBox( width: 460, child: Form( key: _formKey, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField( initialValue: _account, isExpanded: true, decoration: const InputDecoration( labelText: 'Брокерский счёт', ), items: [ for (final a in accounts) DropdownMenuItem(value: a, child: Text(a.name)), ], validator: (v) => v == null ? 'Выберите счёт' : null, onChanged: (v) => setState(() => _account = v), ), const SizedBox(height: 12), DropdownButtonFormField( initialValue: _kind, isExpanded: true, decoration: const InputDecoration(labelText: 'Событие'), items: [ for (final k in manualEventKinds) DropdownMenuItem( value: k, child: Text(eventKindLabel(k)), ), ], onChanged: (v) => setState(() { _kind = v ?? _kind; // the field is gone for kinds without an instrument: its pick must go too if (!_needsInstrument) _instrument = null; }), ), const SizedBox(height: 12), Row( children: [ Expanded(child: Text('Дата: ${ruDate(_date)}')), TextButton( onPressed: () async { final picked = await showDatePicker( context: context, initialDate: _date, firstDate: DateTime(2015), lastDate: DateTime.now(), ); if (picked != null) setState(() => _date = picked); }, child: const Text('Выбрать'), ), ], ), if (_needsInstrument) ...[ const SizedBox(height: 8), _InstrumentField( onChanged: (i) => _instrument = i, validator: () => _instrument == null ? 'Выберите инструмент из списка' : null, ), ], if (trade || _isTransfer(_kind)) ...[ const SizedBox(height: 12), TextFormField( controller: _quantity, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: const InputDecoration( labelText: 'Количество, шт.', ), validator: (v) => _positive(v, required: true), ), ], if (trade) ...[ const SizedBox(height: 12), TextFormField( controller: _price, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: const InputDecoration( labelText: 'Цена за штуку', ), validator: (v) => _positive(v, required: _text(_amount) == null), ), const SizedBox(height: 12), TextFormField( controller: _fee, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: const InputDecoration( labelText: 'Комиссия', helperText: 'Входит в итоговую сумму', ), validator: (v) => _positive(v, required: false), ), const SizedBox(height: 12), TextFormField( controller: _accrued, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: const InputDecoration( labelText: 'НКД (для облигаций)', ), validator: (v) => _positive(v, required: false), ), ], if (!_isTransfer(_kind)) ...[ const SizedBox(height: 12), TextFormField( controller: _amount, keyboardType: const TextInputType.numberWithOptions( decimal: true, ), decoration: InputDecoration( labelText: trade ? 'Итого по выписке' : 'Сумма', helperText: trade ? 'Необязательно: иначе количество × цена ± НКД ± комиссия' : 'Положительная; знак зададут по виду события', helperMaxLines: 2, ), validator: (v) => _positive(v, required: !trade), ), ], const SizedBox(height: 12), TextFormField( controller: _description, decoration: const InputDecoration( labelText: 'Описание (необязательно)', ), ), ], ), ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Отмена'), ), FilledButton( onPressed: () { if (!(_formKey.currentState?.validate() ?? false)) return; Navigator.of(context).pop(_build()); }, child: const Text('Добавить'), ), ], ); } } /// Type-ahead over `GET /instruments?q=`. The chosen instrument is reported through /// [onChanged]; editing the text afterwards drops it, so a stale pick can never be submitted. class _InstrumentField extends ConsumerWidget { const _InstrumentField({required this.onChanged, required this.validator}); final ValueChanged onChanged; final String? Function() validator; static String _label(InstrumentOut i) => '${i.ticker ?? '—'} · ${i.name}'; @override Widget build(BuildContext context, WidgetRef ref) { return Autocomplete( displayStringForOption: _label, optionsBuilder: (value) async { final q = value.text.trim(); if (q.length < 2) return const []; return ref.read(instrumentSearchProvider(q).future); }, onSelected: onChanged, fieldViewBuilder: (context, controller, focusNode, onSubmit) => TextFormField( controller: controller, focusNode: focusNode, decoration: const InputDecoration( labelText: 'Инструмент', helperText: 'Тикер, ISIN или название', prefixIcon: Icon(Icons.search), ), onChanged: (_) => onChanged(null), validator: (_) => validator(), ), ); } }