Files
fin-tracker/app/lib/features/events/manual_event_dialog.dart
T
Dmitry 62d36aa3e8 feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
2026-09-19 22:14:21 +03:00

318 lines
12 KiB
Dart

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<ManualEventCreate?> showManualEventDialog(BuildContext context) =>
showDialog<ManualEventCreate>(
context: context,
builder: (_) => const _ManualEventDialog(),
);
class _ManualEventDialog extends ConsumerStatefulWidget {
const _ManualEventDialog();
@override
ConsumerState<_ManualEventDialog> createState() => _ManualEventDialogState();
}
class _ManualEventDialogState extends ConsumerState<_ManualEventDialog> {
final _formKey = GlobalKey<FormState>();
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 <AccountOut>[])
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<AccountOut>(
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<EventKind>(
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<InstrumentOut?> onChanged;
final String? Function() validator;
static String _label(InstrumentOut i) => '${i.ticker ?? '—'} · ${i.name}';
@override
Widget build(BuildContext context, WidgetRef ref) {
return Autocomplete<InstrumentOut>(
displayStringForOption: _label,
optionsBuilder: (value) async {
final q = value.text.trim();
if (q.length < 2) return const <InstrumentOut>[];
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(),
),
);
}
}