Files
Dmitry 62d36aa3e8 feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
2026-09-19 22:14:21 +03:00

358 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/api/common_providers.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/widgets/service_mark.dart';
import '../accounts/account_create_dialog.dart';
import '../home/providers.dart' show dataQualityProvider;
import 'nav_destinations.dart';
/// The widest the page content (and the content of the top bar) grows: on a big monitor a table
/// stretched edge to edge is harder to read than one that stops.
const kContentMaxWidth = 1440.0;
/// The top bar of the wide layout (>=600): the primary destinations as pills, the ledger
/// screens behind «Операции», and on the right «Добавить», the data-health counter, settings
/// and the profile. Below 600 the shell uses a bottom bar instead.
class TopNav extends ConsumerWidget {
const TopNav({
required this.selectedIndex,
required this.onSelect,
super.key,
});
final int selectedIndex;
final ValueChanged<int> onSelect;
int _indexOf(String path) =>
navDestinations.indexWhere((d) => d.path == path);
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
final compact = MediaQuery.sizeOf(context).width < 900;
final issueCount =
ref.watch(dataQualityProvider).valueOrNull?.data.length ?? 0;
final primary = [
for (var i = 0; i < navDestinations.length; i++)
if (navDestinations[i].primary) i,
];
final ledger = [
for (var i = 0; i < navDestinations.length; i++)
if (navDestinations[i].group == NavGroup.ledger) i,
];
final health = _indexOf('/health');
final settings = _indexOf('/settings');
return Container(
height: 64,
decoration: BoxDecoration(
color: scheme.surfaceContainerLowest,
border: Border(bottom: BorderSide(color: scheme.outlineVariant)),
),
// the band runs edge to edge, but what is on it lines up with the page content below:
// same maximum width, same 16 px side padding as the cards
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kContentMaxWidth),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Tooltip(
message: 'Обзор',
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => onSelect(_indexOf('/')),
child: const ServiceMark(),
),
),
SizedBox(width: compact ? 12 : 28),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final i in primary)
_Pill(
label: navDestinations[i].label,
selected: i == selectedIndex,
onTap: () => onSelect(i),
),
_LedgerMenu(
indices: ledger,
selectedIndex: selectedIndex,
onSelect: onSelect,
),
],
),
),
),
_AddMenu(compact: compact),
const SizedBox(width: 4),
IconButton(
tooltip: 'Здоровье данных',
onPressed: () => onSelect(health),
icon: Badge(
isLabelVisible: issueCount > 0,
label: Text('$issueCount'),
child: Icon(
Icons.monitor_heart_outlined,
color: selectedIndex == health
? scheme.primary
: scheme.onSurfaceVariant,
),
),
),
IconButton(
tooltip: 'Настройки',
onPressed: () => onSelect(settings),
icon: Icon(
Icons.settings_outlined,
color: selectedIndex == settings
? scheme.primary
: scheme.onSurfaceVariant,
),
),
const _ProfileMenu(),
],
),
),
),
),
);
}
}
class _Pill extends StatelessWidget {
const _Pill({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(right: 4),
child: Material(
color: selected ? scheme.surfaceContainerHighest : Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Text(
label,
style: TextStyle(
color: selected ? scheme.onSurface : scheme.onSurfaceVariant,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
),
),
),
);
}
}
/// «Операции»: the ledger screens, which are visited less often than the four main tabs.
class _LedgerMenu extends StatelessWidget {
const _LedgerMenu({
required this.indices,
required this.selectedIndex,
required this.onSelect,
});
final List<int> indices;
final int selectedIndex;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final active = indices.contains(selectedIndex);
return PopupMenuButton<int>(
tooltip: 'Операции',
position: PopupMenuPosition.under,
onSelected: onSelect,
itemBuilder: (_) => [
for (final i in indices)
PopupMenuItem(
value: i,
child: Row(
children: [
Icon(
navDestinations[i].icon,
size: 20,
color: scheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Text(navDestinations[i].label),
],
),
),
],
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: active ? scheme.surfaceContainerHighest : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
active ? navDestinations[selectedIndex].label : 'Операции',
style: TextStyle(
color: active ? scheme.onSurface : scheme.onSurfaceVariant,
fontWeight: active ? FontWeight.w600 : FontWeight.w500,
),
),
Icon(
Icons.arrow_drop_down,
size: 20,
color: scheme.onSurfaceVariant,
),
],
),
),
);
}
}
/// «Добавить»: the four things a person creates by hand.
class _AddMenu extends StatelessWidget {
const _AddMenu({required this.compact});
final bool compact;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return PopupMenuButton<String>(
tooltip: 'Добавить',
position: PopupMenuPosition.under,
onSelected: (v) => switch (v) {
'event' => context.go('/events'),
'account' => showAccountCreateDialog(context),
'portfolio' => context.go('/portfolios'),
_ => context.go('/imports'),
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'event',
child: ListTile(
dense: true,
leading: Icon(Icons.swap_vert),
title: Text('Событие'),
subtitle: Text('Сделка, пополнение, выплата'),
),
),
PopupMenuItem(
value: 'account',
child: ListTile(
dense: true,
leading: Icon(Icons.account_balance_outlined),
title: Text('Брокерский счёт'),
),
),
PopupMenuItem(
value: 'portfolio',
child: ListTile(
dense: true,
leading: Icon(Icons.pie_chart_outline),
title: Text('Портфель'),
),
),
PopupMenuItem(
value: 'import',
child: ListTile(
dense: true,
leading: Icon(Icons.upload_file_outlined),
title: Text('Отчёт брокера'),
),
),
],
child: Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 16,
vertical: 10,
),
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add_circle_outline, size: 20, color: scheme.primary),
if (!compact) ...[
const SizedBox(width: 8),
Text(
'Добавить',
style: TextStyle(
color: scheme.primary,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
);
}
}
class _ProfileMenu extends ConsumerWidget {
const _ProfileMenu();
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
final email = ref.watch(meProvider).valueOrNull?.email;
final initial = (email?.isNotEmpty ?? false)
? email![0].toUpperCase()
: '?';
return PopupMenuButton<String>(
tooltip: email ?? 'Профиль',
position: PopupMenuPosition.under,
onSelected: (_) => ref.read(authControllerProvider.notifier).logout(),
itemBuilder: (_) => [
PopupMenuItem(enabled: false, child: Text(email ?? '…')),
const PopupMenuItem(
value: 'logout',
child: ListTile(
dense: true,
leading: Icon(Icons.logout),
title: Text('Выйти'),
),
),
],
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: CircleAvatar(
radius: 18,
backgroundColor: scheme.primaryContainer,
child: Text(
initial,
style: TextStyle(
color: scheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
),
),
),
);
}
}