Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
318 lines
12 KiB
Dart
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(),
|
|
),
|
|
);
|
|
}
|
|
}
|