feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user