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
+53 -10
View File
@@ -1,15 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
/// Аналитика: a hub for the phase-4 screens.
import '../portfolio/holdings_tab.dart';
import '../portfolio/overview_tiles.dart';
import '../portfolio/scope_selector.dart';
/// Аналитика → Общее. On a wide surface it is Snowball's overview — the four headline figures
/// for the chosen scope, then the value chart, returns and benchmark comparison; the other
/// analytics screens are one tab away (`AnalyticsTabs`). On a phone there is no tab strip, so
/// the same entry is a list of the sections instead.
///
/// Why a hub instead of four more destinations: the shell already carried eleven, which on
/// a phone leaves ~36 px per label in the bottom bar. Доходы, Ребалансировка, Цели and
/// Налоги are all "what do I do with the portfolio next" questions, so they live behind one
/// entry that highlights for all four (see `alsoMatches` in `app_shell.dart`), while each
/// keeps its own top-level route from the contract (`/income`, `/rebalance`, `/goals`,
/// `/tax`) and is deep-linkable.
class AnalyticsHubPage extends StatelessWidget {
/// The sections keep their own top-level routes from the contract (`/income`, `/rebalance`,
/// `/goals`, `/tax`) and are deep-linkable; `alsoMatches` in `nav_destinations.dart` keeps
/// Аналитика highlighted inside any of them.
class AnalyticsHubPage extends ConsumerWidget {
const AnalyticsHubPage({super.key});
static const _entries = [
@@ -23,7 +28,8 @@ class AnalyticsHubPage extends StatelessWidget {
path: '/rebalance',
icon: Icons.balance,
title: 'Ребалансировка',
subtitle: 'Целевые веса портфеля и рекомендации, что докупить или продать',
subtitle:
'Целевые веса портфеля и рекомендации, что докупить или продать',
),
(
path: '/goals',
@@ -39,6 +45,43 @@ class AnalyticsHubPage extends StatelessWidget {
),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
if (MediaQuery.sizeOf(context).width >= 600) return const _Overview();
return const _SectionList();
}
}
class _Overview extends StatelessWidget {
const _Overview();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Align(
alignment: Alignment.centerRight,
child: ScopeSelector(),
),
),
const Expanded(
child: HoldingsTab(
sections: HoldingsSections.overview,
header: OverviewTiles(),
),
),
],
),
);
}
}
class _SectionList extends StatelessWidget {
const _SectionList();
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -46,7 +89,7 @@ class AnalyticsHubPage extends StatelessWidget {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
for (final e in _entries)
for (final e in AnalyticsHubPage._entries)
Card(
child: ListTile(
leading: Icon(e.icon),
+101
View File
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
/// The tab strip under the top bar on every «Аналитика» screen. Each tab keeps its own
/// top-level route (`/income`, `/rebalance`, …), so the screens stay deep-linkable; the strip
/// only makes them read as the parts of one section.
class AnalyticsTabs extends StatelessWidget {
const AnalyticsTabs({required this.location, super.key});
final String location;
static const tabs = [
(path: '/analytics', icon: Icons.work_outline, label: 'Общее'),
(path: '/income', icon: Icons.bar_chart, label: 'Дивиденды'),
(path: '/rebalance', icon: Icons.balance, label: 'Ребалансировка'),
(path: '/goals', icon: Icons.flag_outlined, label: 'Цели'),
(path: '/tax', icon: Icons.receipt_long_outlined, label: 'Налоги'),
(path: '/portfolios', icon: Icons.pie_chart_outline, label: 'Портфели'),
];
/// Whether [location] belongs to the analytics section at all.
static bool contains(String location) =>
tabs.any((t) => location.startsWith(t.path));
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: scheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final t in tabs)
_Tab(
icon: t.icon,
label: t.label,
selected: location.startsWith(t.path),
onTap: () => context.go(t.path),
),
],
),
),
);
}
}
class _Tab extends StatelessWidget {
const _Tab({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = selected ? scheme.onSurface : scheme.onSurfaceVariant;
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? scheme.primary : Colors.transparent,
width: 2,
),
),
),
child: Row(
children: [
Icon(
icon,
size: 20,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
color: color,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
],
),
),
);
}
}
+34 -29
View File
@@ -1,21 +1,22 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'analytics_tabs.dart';
import 'nav_destinations.dart';
import 'nav_sidebar.dart';
import 'top_nav.dart';
/// Breakpoints per the plan: bottom bar under 600, collapsed rail 6001200,
/// extended rail at 1200 and above.
/// Below this width the shell falls back to a bottom [NavigationBar]; from here up it is the
/// [TopNav] bar.
const _narrowBreakpoint = 600.0;
const _wideBreakpoint = 1200.0;
/// Adaptive navigation shell around the current route: a bottom
/// [NavigationBar] on narrow surfaces, a collapsed [NavigationRail] on medium
/// ones, and the grouped [NavSidebar] on wide ones.
/// Adaptive navigation shell around the current route: a bottom [NavigationBar] on narrow
/// surfaces and the [TopNav] bar on wider ones, with the Аналитика tab strip under it while
/// one of the analytics screens is open.
///
/// The rail and the sidebar show every destination. The bottom bar shows the four primary
/// ones plus «Ещё», which opens the rest in a sheet: twelve destinations in a phone-width
/// bar would leave about 30 px per label, which is not navigation but decoration.
/// The top bar shows every destination (the ledger ones behind «Операции»). The bottom bar
/// shows the four primary ones plus «Ещё», which opens the rest in a sheet: twelve
/// destinations in a phone-width bar would leave about 30 px per label, which is not
/// navigation but decoration.
class AppShell extends StatelessWidget {
const AppShell({required this.location, required this.child, super.key});
@@ -80,28 +81,32 @@ class AppShell extends StatelessWidget {
);
}
final extended = width >= _wideBreakpoint;
return Scaffold(
body: Row(
body: Column(
children: [
if (extended)
NavSidebar(selectedIndex: _selectedIndex, onSelect: (i) => _onSelect(context, i))
else
NavigationRail(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => _onSelect(context, i),
labelType: NavigationRailLabelType.selected,
destinations: [
for (final d in navDestinations)
NavigationRailDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: Text(d.label),
),
],
TopNav(
selectedIndex: _selectedIndex,
onSelect: (i) => _onSelect(context, i),
),
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kContentMaxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (AnalyticsTabs.contains(location))
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: AnalyticsTabs(location: location),
),
Expanded(child: child),
],
),
),
),
const VerticalDivider(width: 1),
Expanded(child: SafeArea(child: child)),
),
],
),
);
+100 -30
View File
@@ -1,18 +1,19 @@
import 'package:flutter/material.dart';
/// The three groups the sidebar buckets destinations under (Grimmory-style caps headers).
/// Order here is display order, top to bottom.
/// The groups destinations belong to: the main tabs, the ledger screens behind «Операции», and
/// the system ones (Здоровье, Настройки) that sit as icons on the right of the top bar.
enum NavGroup { overview, ledger, system }
const navGroupLabels = {
NavGroup.overview: 'ОБЗОР',
NavGroup.ledger: 'ОПЕРАЦИИ',
NavGroup.system: 'СИСТЕМА',
};
class NavDestination {
const NavDestination(this.path, this.icon, this.selectedIcon, this.label, this.group,
{this.alsoMatches = const [], this.primary = false});
const NavDestination(
this.path,
this.icon,
this.selectedIcon,
this.label,
this.group, {
this.alsoMatches = const [],
this.primary = false,
});
final String path;
final IconData icon;
final IconData selectedIcon;
@@ -39,28 +40,97 @@ class NavDestination {
}
const navDestinations = [
NavDestination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор', NavGroup.overview,
primary: true),
NavDestination(
'/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета', NavGroup.overview,
primary: true),
'/',
Icons.dashboard_outlined,
Icons.dashboard,
'Обзор',
NavGroup.overview,
primary: true,
),
// One entry for the phase-4 screens. They keep their own top-level routes from the
// contract and stay deep-linkable; `/analytics` is what the bars point at, and
// `alsoMatches` keeps it highlighted while you are inside any of them.
NavDestination(
'/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель', NavGroup.overview,
primary: true),
// One entry for the four phase-4 screens. They keep their own top-level routes from the
// contract and stay deep-linkable; the hub at `/analytics` is what the bottom bar and the
// rail point at, and `alsoMatches` keeps it highlighted while you are inside any of them.
NavDestination('/analytics', Icons.insights_outlined, Icons.insights, 'Аналитика', NavGroup.overview,
alsoMatches: ['/income', '/rebalance', '/goals', '/tax'], primary: true),
NavDestination('/events', Icons.event_note_outlined, Icons.event_note, 'События', NavGroup.ledger),
NavDestination('/imports', Icons.upload_file_outlined, Icons.upload_file, 'Импорт', NavGroup.ledger,
alsoMatches: ['/instruments/pending']),
NavDestination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки', NavGroup.ledger),
NavDestination('/categories', Icons.category_outlined, Icons.category, 'Категории', NavGroup.ledger),
'/analytics',
Icons.insights_outlined,
Icons.insights,
'Аналитика',
NavGroup.overview,
alsoMatches: ['/income', '/rebalance', '/goals', '/tax', '/portfolios'],
primary: true,
),
NavDestination(
'/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции', NavGroup.ledger),
NavDestination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила', NavGroup.ledger),
'/portfolio',
Icons.pie_chart_outline,
Icons.pie_chart,
'Портфель',
NavGroup.overview,
primary: true,
),
NavDestination(
'/health', Icons.monitor_heart_outlined, Icons.monitor_heart, 'Здоровье', NavGroup.system),
NavDestination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки', NavGroup.system),
'/accounts',
Icons.account_balance_outlined,
Icons.account_balance,
'Счета',
NavGroup.overview,
primary: true,
),
NavDestination(
'/events',
Icons.event_note_outlined,
Icons.event_note,
'События',
NavGroup.ledger,
),
NavDestination(
'/imports',
Icons.upload_file_outlined,
Icons.upload_file,
'Импорт',
NavGroup.ledger,
alsoMatches: ['/instruments/pending'],
),
NavDestination(
'/cashflow',
Icons.swap_horiz_outlined,
Icons.swap_horiz,
'Потоки',
NavGroup.ledger,
),
NavDestination(
'/categories',
Icons.category_outlined,
Icons.category,
'Категории',
NavGroup.ledger,
),
NavDestination(
'/transactions',
Icons.receipt_long_outlined,
Icons.receipt_long,
'Операции',
NavGroup.ledger,
),
NavDestination(
'/rules',
Icons.rule_folder_outlined,
Icons.rule_folder,
'Правила',
NavGroup.ledger,
),
NavDestination(
'/health',
Icons.monitor_heart_outlined,
Icons.monitor_heart,
'Здоровье',
NavGroup.system,
),
NavDestination(
'/settings',
Icons.settings_outlined,
Icons.settings,
'Настройки',
NavGroup.system,
),
];
-199
View File
@@ -1,199 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/common_providers.dart';
import '../home/providers.dart' show dataQualityProvider;
import 'nav_destinations.dart';
/// The Grimmory-style sidebar shown at the extended breakpoint (>=1200): destinations
/// grouped under muted caps headers, a left accent bar on the active row, a counter next
/// to a destination where one is meaningful, and the signed-in account pinned at the
/// bottom. Below 1200 the shell falls back to a plain [NavigationRail] — there is no room
/// for group headers or a profile block in an icon-only rail.
class NavSidebar extends ConsumerWidget {
const NavSidebar({required this.selectedIndex, required this.onSelect, super.key});
final int selectedIndex;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context, WidgetRef ref) {
final issueCount = ref.watch(dataQualityProvider).valueOrNull?.data.length;
final byGroup = <NavGroup, List<int>>{};
for (var i = 0; i < navDestinations.length; i++) {
byGroup.putIfAbsent(navDestinations[i].group, () => []).add(i);
}
return Container(
width: 240,
color: Theme.of(context).colorScheme.surfaceContainerLow,
child: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
for (final group in NavGroup.values)
if (byGroup[group] case final indices?)
_NavGroupSection(
label: navGroupLabels[group]!,
children: [
for (final i in indices)
_NavRow(
destination: navDestinations[i],
selected: i == selectedIndex,
counter: navDestinations[i].path == '/health' ? issueCount : null,
onTap: () => onSelect(i),
),
],
),
],
),
),
const Divider(height: 1),
const _ProfileFooter(),
],
),
);
}
}
class _NavGroupSection extends StatelessWidget {
const _NavGroupSection({required this.label, required this.children});
final String label;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 12, 6),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: scheme.onSurfaceVariant,
letterSpacing: 1.1,
fontWeight: FontWeight.w600,
),
),
),
...children,
],
);
}
}
class _NavRow extends StatelessWidget {
const _NavRow({required this.destination, required this.selected, required this.onTap, this.counter});
final NavDestination destination;
final bool selected;
final VoidCallback onTap;
final int? counter;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 12, 2),
child: Material(
color: selected ? scheme.primaryContainer.withValues(alpha: 0.35) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 9),
child: Row(
children: [
Container(
width: 3,
height: 18,
margin: const EdgeInsets.only(right: 9),
decoration: BoxDecoration(
color: selected ? scheme.primary : Colors.transparent,
borderRadius: BorderRadius.circular(2),
),
),
Icon(
selected ? destination.selectedIcon : destination.icon,
size: 20,
color: selected ? scheme.primary : scheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
destination.label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: selected ? scheme.primary : scheme.onSurface,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
),
if (counter != null && counter! > 0)
Text(
'$counter',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant),
),
],
),
),
),
),
);
}
}
class _ProfileFooter extends ConsumerWidget {
const _ProfileFooter();
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
final theme = Theme.of(context);
final email = ref.watch(meProvider).valueOrNull?.email;
final name = email?.split('@').first;
final initial = (name?.isNotEmpty ?? false) ? name![0].toUpperCase() : '?';
return Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: scheme.primaryContainer,
child: Text(
initial,
style: TextStyle(color: scheme.onPrimaryContainer, fontWeight: FontWeight.w700),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name ?? '',
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
Text(
email ?? '',
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(color: scheme.onSurfaceVariant),
),
],
),
),
],
),
);
}
}
+357
View File
@@ -0,0 +1,357 @@
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,
),
),
),
),
);
}
}