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
@@ -0,0 +1,171 @@
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 '../../core/api/api_client.dart';
import '../../core/auth/auth_controller.dart';
import 'providers.dart';
/// The brokers whose accounts are created by hand: T-Invest accounts come from the sync.
const _brokerChoices = [
(Broker.sber, 'Сбер'),
(Broker.vtb, 'ВТБ'),
(Broker.other, 'Другой (CSV)'),
];
/// Maps the import contract's broker key (`sber | vtb | csv`) onto a creatable broker.
Broker? brokerFromImportKey(String? key) => switch (key) {
'sber' => Broker.sber,
'vtb' => Broker.vtb,
'csv' => Broker.other,
_ => null,
};
/// Asks for the details of a broker account and creates it. Returns the new account, or null
/// when cancelled or when the server refused (the reason is shown in the dialog).
Future<AccountOut?> showAccountCreateDialog(
BuildContext context, {
Broker? broker,
String? sourceId,
String? name,
}) => showDialog<AccountOut>(
context: context,
builder: (_) =>
_AccountCreateDialog(broker: broker, sourceId: sourceId, name: name),
);
class _AccountCreateDialog extends ConsumerStatefulWidget {
const _AccountCreateDialog({this.broker, this.sourceId, this.name});
final Broker? broker;
final String? sourceId;
final String? name;
@override
ConsumerState<_AccountCreateDialog> createState() =>
_AccountCreateDialogState();
}
class _AccountCreateDialogState extends ConsumerState<_AccountCreateDialog> {
final _formKey = GlobalKey<FormState>();
late final _name = TextEditingController(text: widget.name ?? '');
late final _sourceId = TextEditingController(text: widget.sourceId ?? '');
final _currency = TextEditingController(text: 'RUB');
late Broker _broker = widget.broker ?? Broker.sber;
bool _saving = false;
String? _error;
@override
void dispose() {
_name.dispose();
_sourceId.dispose();
_currency.dispose();
super.dispose();
}
Future<void> _save() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
setState(() {
_saving = true;
_error = null;
});
try {
final r = await ref
.read(apiProvider)
.getAccountsApi()
.accountsCreate(
accountCreate: AccountCreate(
name: _name.text.trim(),
broker: _broker,
sourceId: _sourceId.text.trim(),
currency: _currency.text.trim().toUpperCase(),
),
);
ref.invalidate(accountsProvider);
if (mounted) Navigator.of(context).pop(r.data);
} on DioException catch (e) {
if (mounted) {
setState(() {
_saving = false;
_error = problemMessage(e);
});
}
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Новый брокерский счёт'),
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<Broker>(
initialValue: _broker,
decoration: const InputDecoration(labelText: 'Брокер'),
items: [
for (final (b, label) in _brokerChoices)
DropdownMenuItem(value: b, child: Text(label)),
],
onChanged: (b) => setState(() => _broker = b ?? _broker),
),
const SizedBox(height: 12),
TextFormField(
controller: _sourceId,
decoration: const InputDecoration(
labelText: 'Номер договора',
helperText: 'Как напечатан в отчёте брокера — по нему импорт находит счёт',
helperMaxLines: 2,
),
validator: (v) =>
(v ?? '').trim().isEmpty ? 'Обязательное поле' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _currency,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(labelText: 'Валюта'),
validator: (v) => (v ?? '').trim().length == 3
? null
: 'Три буквы, например RUB',
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(
_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
],
),
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Отмена'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: const Text('Создать'),
),
],
);
}
}