feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -42,7 +42,12 @@ class EventRow extends StatelessWidget {
|
||||
leading: Icon(icon, color: color),
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(child: Text(eventTitle(e.kind, e.ticker), overflow: TextOverflow.ellipsis)),
|
||||
Flexible(
|
||||
child: Text(
|
||||
eventTitle(e.kind, e.ticker),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (e.externalFlow) ...[
|
||||
const SizedBox(width: 6),
|
||||
Tooltip(
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/help_tip.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/money_text.dart';
|
||||
import '../accounts/providers.dart';
|
||||
import '../portfolio/labels.dart' show formatQty, signColor;
|
||||
import 'event_row.dart';
|
||||
import 'labels.dart';
|
||||
import 'manual_event_dialog.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
/// События: the broker ledger with filters and infinite scroll — the screen that answers
|
||||
@@ -44,7 +49,8 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) {
|
||||
if (_scrollController.position.pixels >
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
ref.read(eventsControllerProvider.notifier).loadMore();
|
||||
}
|
||||
}
|
||||
@@ -52,8 +58,11 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
void _onSearchChanged(String value) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 400), () {
|
||||
ref.read(eventsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
|
||||
ref
|
||||
.read(eventsControllerProvider.notifier)
|
||||
.setFilter(
|
||||
(f) =>
|
||||
f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -71,16 +80,79 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
helpText: 'Период',
|
||||
);
|
||||
if (picked != null) {
|
||||
ref.read(eventsControllerProvider.notifier).setFilter(
|
||||
ref
|
||||
.read(eventsControllerProvider.notifier)
|
||||
.setFilter(
|
||||
(f) => f.copyWith(from: () => picked.start, to: () => picked.end),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearDateRange() {
|
||||
ref.read(eventsControllerProvider.notifier).setFilter(
|
||||
(f) => f.copyWith(from: () => null, to: () => null),
|
||||
);
|
||||
ref
|
||||
.read(eventsControllerProvider.notifier)
|
||||
.setFilter((f) => f.copyWith(from: () => null, to: () => null));
|
||||
}
|
||||
|
||||
void _snack(String message) =>
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
|
||||
/// Lots and every valuation are rebuilt from the ledger by the metrics refresh, so a change
|
||||
/// to the ledger asks for one. Best effort: the event itself is already saved.
|
||||
Future<void> _queueMetricsRefresh() async {
|
||||
try {
|
||||
await ref.read(apiProvider).getMetricsApi().metricsRefresh();
|
||||
} on DioException {
|
||||
// the next scheduled or manual refresh picks the change up
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addManual() async {
|
||||
final body = await showManualEventDialog(context);
|
||||
if (body == null || !mounted) return;
|
||||
try {
|
||||
await ref
|
||||
.read(apiProvider)
|
||||
.getEventsApi()
|
||||
.eventsCreate(manualEventCreate: body);
|
||||
await ref.read(eventsControllerProvider.notifier).refresh();
|
||||
await _queueMetricsRefresh();
|
||||
if (mounted) _snack('Событие добавлено, метрики пересчитываются');
|
||||
} on DioException catch (e) {
|
||||
if (mounted) _snack(problemMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteManual(EventOut e) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Удалить событие?'),
|
||||
content: Text(
|
||||
'«${eventTitle(e.kind, e.ticker)}» от ${ruDate(e.tradeDate)} будет удалено.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Удалить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
try {
|
||||
await ref.read(apiProvider).getEventsApi().eventsDelete(eventId: e.id);
|
||||
await ref.read(eventsControllerProvider.notifier).refresh();
|
||||
await _queueMetricsRefresh();
|
||||
if (mounted) _snack('Событие удалено, метрики пересчитываются');
|
||||
} on DioException catch (err) {
|
||||
if (mounted) _snack(problemMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
void _showDetail(EventOut e) {
|
||||
@@ -88,7 +160,16 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _EventDetailSheet(event: e, accountNames: accountNames),
|
||||
builder: (sheetContext) => _EventDetailSheet(
|
||||
event: e,
|
||||
accountNames: accountNames,
|
||||
onDelete: e.source_ == 'manual'
|
||||
? () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_deleteManual(e);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,16 +178,24 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
final state = ref.watch(eventsControllerProvider);
|
||||
final filter = ref.watch(eventsControllerProvider.notifier).filter;
|
||||
final accountNames = ref.watch(accountNamesProvider);
|
||||
final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
|
||||
final accounts =
|
||||
ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _addManual,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Событие'),
|
||||
),
|
||||
appBar: AppBar(
|
||||
title: const Text('События'),
|
||||
actions: [
|
||||
if (state.total > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Center(child: Text('${state.items.length} из ${state.total}')),
|
||||
child: Center(
|
||||
child: Text('${state.items.length} из ${state.total}'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -138,12 +227,17 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('${state.error}', textAlign: TextAlign.center),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.read(eventsControllerProvider.notifier).refresh(),
|
||||
onPressed: () =>
|
||||
ref.read(eventsControllerProvider.notifier).refresh(),
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
@@ -171,7 +265,9 @@ class _EventsPageState extends ConsumerState<EventsPage> {
|
||||
child: state.loadingMore
|
||||
? const CircularProgressIndicator()
|
||||
: TextButton(
|
||||
onPressed: () => ref.read(eventsControllerProvider.notifier).loadMore(),
|
||||
onPressed: () => ref
|
||||
.read(eventsControllerProvider.notifier)
|
||||
.loadMore(),
|
||||
child: const Text('Ещё'),
|
||||
),
|
||||
),
|
||||
@@ -246,51 +342,67 @@ class _Filters extends ConsumerWidget {
|
||||
width: 180,
|
||||
child: DropdownButtonFormField<int?>(
|
||||
initialValue: filter.accountId,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Счёт', isDense: true),
|
||||
decoration: const InputDecoration(labelText: 'Счёт'),
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Все счета')),
|
||||
const DropdownMenuItem(
|
||||
value: null,
|
||||
child: Text('Все счета'),
|
||||
),
|
||||
for (final a in accounts)
|
||||
DropdownMenuItem(value: a.id, child: Text(a.name)),
|
||||
],
|
||||
onChanged: (v) => notifier.setFilter((f) => f.copyWith(accountId: () => v)),
|
||||
onChanged: (v) =>
|
||||
notifier.setFilter((f) => f.copyWith(accountId: () => v)),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonFormField<EventKind?>(
|
||||
initialValue: filter.kind,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Тип', isDense: true),
|
||||
decoration: const InputDecoration(labelText: 'Тип'),
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Все типы')),
|
||||
const DropdownMenuItem(
|
||||
value: null,
|
||||
child: Text('Все типы'),
|
||||
),
|
||||
for (final k in filterableEventKinds)
|
||||
DropdownMenuItem(value: k, child: Text(eventKindLabel(k))),
|
||||
DropdownMenuItem(
|
||||
value: k,
|
||||
child: Text(eventKindLabel(k)),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => notifier.setFilter((f) => f.copyWith(kind: () => v)),
|
||||
onChanged: (v) =>
|
||||
notifier.setFilter((f) => f.copyWith(kind: () => v)),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonFormField<EventStatus?>(
|
||||
initialValue: filter.status,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(labelText: 'Статус', isDense: true),
|
||||
decoration: const InputDecoration(labelText: 'Статус'),
|
||||
items: [
|
||||
const DropdownMenuItem(value: null, child: Text('Любой статус')),
|
||||
const DropdownMenuItem(
|
||||
value: null,
|
||||
child: Text('Любой статус'),
|
||||
),
|
||||
for (final s in EventStatus.values)
|
||||
if (s != EventStatus.unknownDefaultOpenApi)
|
||||
DropdownMenuItem(value: s, child: Text(eventStatusLabel(s))),
|
||||
DropdownMenuItem(
|
||||
value: s,
|
||||
child: Text(eventStatusLabel(s)),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => notifier.setFilter((f) => f.copyWith(status: () => v)),
|
||||
onChanged: (v) =>
|
||||
notifier.setFilter((f) => f.copyWith(status: () => v)),
|
||||
),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Внешние потоки'),
|
||||
tooltip: 'Только пополнения, выводы и переводы бумаг — то, что читает XIRR',
|
||||
selected: filter.externalFlow == true,
|
||||
onSelected: (on) =>
|
||||
notifier.setFilter((f) => f.copyWith(externalFlow: () => on ? true : null)),
|
||||
onSelected: (on) => notifier.setFilter(
|
||||
(f) => f.copyWith(externalFlow: () => on ? true : null),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -301,31 +413,45 @@ class _Filters extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _EventDetailSheet extends StatelessWidget {
|
||||
const _EventDetailSheet({required this.event, required this.accountNames});
|
||||
const _EventDetailSheet({
|
||||
required this.event,
|
||||
required this.accountNames,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
final EventOut event;
|
||||
final Map<int, String> accountNames;
|
||||
|
||||
/// Set only for events entered by hand: broker events come back with the next sync.
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final e = event;
|
||||
final theme = Theme.of(context);
|
||||
String money(String? v, String currency) => v == null ? '—' : MoneyText.format(v, currency);
|
||||
String money(String? v, String currency) =>
|
||||
v == null ? '—' : MoneyText.format(v, currency);
|
||||
final rows = <(String, String)>[
|
||||
('Дата сделки', ruDate(e.tradeDate)),
|
||||
('Время', '${ruDate(e.ts)} ${e.ts.hour.toString().padLeft(2, '0')}:'
|
||||
'${e.ts.minute.toString().padLeft(2, '0')}'),
|
||||
(
|
||||
'Время',
|
||||
'${ruDate(e.ts)} ${e.ts.hour.toString().padLeft(2, '0')}:'
|
||||
'${e.ts.minute.toString().padLeft(2, '0')}',
|
||||
),
|
||||
('Тип', eventKindLabel(e.kind)),
|
||||
('Статус', eventStatusLabel(e.status)),
|
||||
('Источник', eventSourceLabel(e.source_)),
|
||||
('Счёт', accountNames[e.accountId] ?? '#${e.accountId}'),
|
||||
if (e.ticker != null) ('Инструмент', e.ticker!),
|
||||
if (e.quantity != null) ('Количество', formatQty(e.quantity!)),
|
||||
if (e.price != null) ('Цена', money(e.price, e.priceCurrency ?? e.currency)),
|
||||
if (e.price != null)
|
||||
('Цена', money(e.price, e.priceCurrency ?? e.currency)),
|
||||
('Сумма', money(e.amount, e.currency)),
|
||||
('Сумма, ₽', money(e.amountRub, 'RUB')),
|
||||
if (e.fee != null) ('Комиссия', money(e.fee, e.currency)),
|
||||
if (e.tax != null) ('Налог', money(e.tax, e.currency)),
|
||||
if (e.accruedInterest != null) ('НКД', money(e.accruedInterest, e.currency)),
|
||||
if (e.accruedInterest != null)
|
||||
('НКД', money(e.accruedInterest, e.currency)),
|
||||
('Внешний поток', e.externalFlow ? 'да' : 'нет'),
|
||||
if (e.description != null) ('Описание', e.description!),
|
||||
];
|
||||
@@ -339,12 +465,16 @@ class _EventDetailSheet extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(eventTitle(e.kind, e.ticker), style: theme.textTheme.titleLarge),
|
||||
child: Text(
|
||||
eventTitle(e.kind, e.ticker),
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
MoneyText.format(e.amount, e.currency),
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(color: signColor(context, e.amount)),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: signColor(context, e.amount),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -357,12 +487,23 @@ class _EventDetailSheet extends StatelessWidget {
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 140,
|
||||
child: Text(label, style: theme.textTheme.bodySmall),
|
||||
child: TermLabel(label, style: theme.textTheme.bodySmall),
|
||||
),
|
||||
Expanded(child: Text(value)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onDelete != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: onDelete,
|
||||
icon: const Icon(Icons.delete_outline, size: 18),
|
||||
label: const Text('Удалить'),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (e.instrumentId != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
|
||||
@@ -35,31 +35,47 @@ const eventStatusLabels = {
|
||||
'ignored': 'Игнорируется',
|
||||
};
|
||||
|
||||
String eventStatusLabel(EventStatus status) => eventStatusLabels[status.value] ?? status.value;
|
||||
String eventStatusLabel(EventStatus status) =>
|
||||
eventStatusLabels[status.value] ?? status.value;
|
||||
|
||||
/// Icon and colour per kind, on the same principle as the transaction list: money coming
|
||||
/// in is green, money leaving is the error colour, everything structural is neutral.
|
||||
(IconData, Color) eventKindIcon(EventKind kind, ColorScheme scheme) => switch (kind) {
|
||||
(IconData, Color) eventKindIcon(EventKind kind, ColorScheme scheme) =>
|
||||
switch (kind) {
|
||||
EventKind.buy => (Icons.add_shopping_cart, scheme.primary),
|
||||
EventKind.sell => (Icons.sell_outlined, Colors.deepPurple),
|
||||
EventKind.dividend || EventKind.coupon || EventKind.interest => (
|
||||
Icons.payments_outlined,
|
||||
Colors.green,
|
||||
),
|
||||
EventKind.dividend ||
|
||||
EventKind.coupon ||
|
||||
EventKind.interest => (Icons.payments_outlined, Colors.green),
|
||||
EventKind.taxRefund => (Icons.assignment_return_outlined, Colors.green),
|
||||
EventKind.tax || EventKind.commission => (Icons.receipt_outlined, scheme.error),
|
||||
EventKind.tax ||
|
||||
EventKind.commission => (Icons.receipt_outlined, scheme.error),
|
||||
EventKind.deposit => (Icons.arrow_circle_down_outlined, Colors.green),
|
||||
EventKind.withdrawal => (Icons.arrow_circle_up_outlined, scheme.error),
|
||||
EventKind.transferIn || EventKind.transferOut => (Icons.swap_horiz, scheme.primary),
|
||||
EventKind.transferIn ||
|
||||
EventKind.transferOut => (Icons.swap_horiz, scheme.primary),
|
||||
EventKind.fxExchange => (Icons.currency_exchange, Colors.amber),
|
||||
EventKind.split || EventKind.amortization || EventKind.repayment => (
|
||||
Icons.call_split,
|
||||
scheme.outline,
|
||||
),
|
||||
EventKind.other || EventKind.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline),
|
||||
EventKind.split ||
|
||||
EventKind.amortization ||
|
||||
EventKind.repayment => (Icons.call_split, scheme.outline),
|
||||
EventKind.other ||
|
||||
EventKind.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline),
|
||||
};
|
||||
|
||||
/// `'Покупка · SBER'`, the one-line identity of a row. Events with no instrument behind
|
||||
/// them (deposits, fees) keep just the kind.
|
||||
String eventTitle(EventKind kind, String? ticker) =>
|
||||
ticker == null || ticker.isEmpty ? eventKindLabel(kind) : '${eventKindLabel(kind)} · $ticker';
|
||||
ticker == null || ticker.isEmpty
|
||||
? eventKindLabel(kind)
|
||||
: '${eventKindLabel(kind)} · $ticker';
|
||||
|
||||
/// Where a ledger event came from, as a person reads it.
|
||||
const eventSourceLabels = {
|
||||
'tinvest': 'T-Invest',
|
||||
'report_sber': 'Отчёт Сбера',
|
||||
'report_vtb': 'Отчёт ВТБ',
|
||||
'csv': 'CSV',
|
||||
'manual': 'Введено вручную',
|
||||
};
|
||||
|
||||
String eventSourceLabel(String source) => eventSourceLabels[source] ?? source;
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -112,7 +112,10 @@ class EventsController extends Notifier<EventsState> {
|
||||
}
|
||||
|
||||
Future<EventPage> _fetch(int page) async {
|
||||
final r = await ref.read(apiProvider).getEventsApi().eventsList(
|
||||
final r = await ref
|
||||
.read(apiProvider)
|
||||
.getEventsApi()
|
||||
.eventsList(
|
||||
from: filter.from,
|
||||
to: filter.to,
|
||||
accountId: filter.accountId,
|
||||
|
||||
Reference in New Issue
Block a user