Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
148 lines
5.3 KiB
Dart
148 lines
5.3 KiB
Dart
import 'package:fintracker_api/fintracker_api.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'labels.dart';
|
|
|
|
/// Edit form for the fields of an instrument a person may correct. Returns a patch holding
|
|
/// only what changed, or null when cancelled or nothing changed.
|
|
class InstrumentEditDialog extends StatefulWidget {
|
|
const InstrumentEditDialog({required this.instrument, super.key});
|
|
|
|
final InstrumentOut instrument;
|
|
|
|
@override
|
|
State<InstrumentEditDialog> createState() => _InstrumentEditDialogState();
|
|
}
|
|
|
|
class _InstrumentEditDialogState extends State<InstrumentEditDialog> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
late final _name = TextEditingController(text: widget.instrument.name);
|
|
late final _board = TextEditingController(
|
|
text: widget.instrument.board ?? '',
|
|
);
|
|
late final _lot = TextEditingController(text: '${widget.instrument.lot}');
|
|
late final _sector = TextEditingController(
|
|
text: widget.instrument.sector ?? '',
|
|
);
|
|
late String _assetClass = widget.instrument.assetClass;
|
|
|
|
@override
|
|
void dispose() {
|
|
_name.dispose();
|
|
_board.dispose();
|
|
_lot.dispose();
|
|
_sector.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
InstrumentPatch? _patch() {
|
|
final i = widget.instrument;
|
|
final name = _name.text.trim();
|
|
final board = _board.text.trim();
|
|
final sector = _sector.text.trim();
|
|
final lot = int.parse(_lot.text.trim());
|
|
final patch = InstrumentPatch(
|
|
name: name != i.name ? name : null,
|
|
assetClass: _assetClass != i.assetClass ? _assetClass : null,
|
|
// an empty field means "leave as is": the wire format cannot express "clear"
|
|
board: board.isNotEmpty && board != (i.board ?? '') ? board : null,
|
|
lot: lot != i.lot ? lot : null,
|
|
sector: sector.isNotEmpty && sector != (i.sector ?? '') ? sector : null,
|
|
);
|
|
final changed =
|
|
patch.name != null ||
|
|
patch.assetClass != null ||
|
|
patch.board != null ||
|
|
patch.lot != null ||
|
|
patch.sector != null;
|
|
return changed ? patch : null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text('Инструмент ${widget.instrument.ticker ?? ''}'.trim()),
|
|
content: SizedBox(
|
|
width: 420,
|
|
child: Form(
|
|
key: _formKey,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextFormField(
|
|
controller: _name,
|
|
decoration: const InputDecoration(labelText: 'Название'),
|
|
validator: (v) =>
|
|
(v ?? '').trim().isEmpty ? 'Обязательное поле' : null,
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: assetClassLabels.containsKey(_assetClass)
|
|
? _assetClass
|
|
: null,
|
|
isExpanded: true,
|
|
decoration: const InputDecoration(labelText: 'Класс актива'),
|
|
items: [
|
|
for (final e in assetClassLabels.entries)
|
|
DropdownMenuItem(
|
|
value: e.key,
|
|
child: Text('${e.value} (${e.key})'),
|
|
),
|
|
],
|
|
onChanged: (v) =>
|
|
setState(() => _assetClass = v ?? _assetClass),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextFormField(
|
|
controller: _board,
|
|
textCapitalization: TextCapitalization.characters,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Доска (TQBR, TQTF…)',
|
|
helperText: 'По ней синк Мосбиржи находит котировки',
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextFormField(
|
|
controller: _lot,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Лот',
|
|
helperText:
|
|
'Ребалансировка округляет сделки до целого числа лотов',
|
|
helperMaxLines: 2,
|
|
),
|
|
validator: (v) {
|
|
final n = int.tryParse((v ?? '').trim());
|
|
return n == null || n < 1
|
|
? 'Целое число не меньше 1'
|
|
: null;
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextFormField(
|
|
controller: _sector,
|
|
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(_patch());
|
|
},
|
|
child: const Text('Сохранить'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|