feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 600–1200,
|
||||
/// 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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user