Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
358 lines
11 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|