feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события

Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
Dmitry
2026-09-19 22:14:21 +03:00
parent a559d6de3e
commit 62d36aa3e8
73 changed files with 6406 additions and 1192 deletions
+6 -1
View File
@@ -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(
+180 -39
View File
@@ -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(
+30 -14
View File
@@ -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(),
),
);
}
}
+4 -1
View File
@@ -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,