feat(app): группированный сайдбар навигации на широком breakpoint

NavSidebar (>=1200): пункты меню сгруппированы под капс-заголовками,
у активного пункта левая акцентная полоска, у «Здоровье» — счётчик
замечаний качества данных из dataQualityProvider, внизу закреплён
профиль (аватар-инициал + email из meProvider). Список направлений
вынесен в nav_destinations.dart — общий для app_shell.dart и
nav_sidebar.dart. На 600–1200 остаётся свёрнутый NavigationRail: в
иконку-без-подписи заголовки групп и профиль всё равно не помещаются.

NavSidebar смонтирован всё время сессии и держит dataQualityProvider
и meProvider живыми — раньше эти запросы уходили только при открытии
Обзора/Здоровья/Настроек.
This commit is contained in:
Dmitry
2026-09-19 15:31:37 +03:00
parent 9d49a9539b
commit 8d404ce7d3
4 changed files with 370 additions and 84 deletions
+31 -77
View File
@@ -1,55 +1,8 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class _Destination {
const _Destination(this.path, this.icon, this.selectedIcon, this.label,
{this.alsoMatches = const [], this.primary = false});
final String path;
final IconData icon;
final IconData selectedIcon;
final String label;
/// Extra route prefixes that belong to this destination but do not start with [path] —
/// `/instruments/pending` is reached from Импорт and must keep it highlighted, and the
/// four phase-4 screens are reached from Аналитика the same way.
final List<String> alsoMatches;
/// Shown directly in the bottom bar on a phone. Everything else moves behind «Ещё».
final bool primary;
/// The longest prefix of [location] this destination claims, or -1 for no match.
int matchLength(String location) {
var best = -1;
for (final p in [path, ...alsoMatches]) {
final hit = p == '/' ? location == '/' : location.startsWith(p);
if (hit && p.length > best) best = p.length;
}
return best;
}
}
const _destinations = [
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор', primary: true),
_Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета',
primary: true),
_Destination('/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель',
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.
_Destination('/analytics', Icons.insights_outlined, Icons.insights, 'Аналитика',
alsoMatches: ['/income', '/rebalance', '/goals', '/tax'], primary: true),
_Destination('/events', Icons.event_note_outlined, Icons.event_note, 'События'),
_Destination('/imports', Icons.upload_file_outlined, Icons.upload_file, 'Импорт',
alsoMatches: ['/instruments/pending']),
_Destination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки'),
_Destination('/categories', Icons.category_outlined, Icons.category, 'Категории'),
_Destination(
'/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции'),
_Destination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила'),
_Destination('/health', Icons.monitor_heart_outlined, Icons.monitor_heart, 'Здоровье'),
_Destination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки'),
];
import 'nav_destinations.dart';
import 'nav_sidebar.dart';
/// Breakpoints per the plan: bottom bar under 600, collapsed rail 6001200,
/// extended rail at 1200 and above.
@@ -57,12 +10,12 @@ const _narrowBreakpoint = 600.0;
const _wideBreakpoint = 1200.0;
/// Adaptive navigation shell around the current route: a bottom
/// [NavigationBar] on narrow surfaces, a [NavigationRail] (collapsed or
/// extended) otherwise.
/// [NavigationBar] on narrow surfaces, a collapsed [NavigationRail] on medium
/// ones, and the grouped [NavSidebar] on wide ones.
///
/// The rail shows 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 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.
class AppShell extends StatelessWidget {
const AppShell({required this.location, required this.child, super.key});
@@ -73,8 +26,8 @@ class AppShell extends StatelessWidget {
// longest prefix wins, so /portfolio/instrument/311 keeps Портфель selected
var best = -1;
var bestLength = -1;
for (var i = 0; i < _destinations.length; i++) {
final length = _destinations[i].matchLength(location);
for (var i = 0; i < navDestinations.length; i++) {
final length = navDestinations[i].matchLength(location);
if (length > bestLength) {
best = i;
bestLength = length;
@@ -84,7 +37,7 @@ class AppShell extends StatelessWidget {
}
void _onSelect(BuildContext context, int index) {
if (index != _selectedIndex) context.go(_destinations[index].path);
if (index != _selectedIndex) context.go(navDestinations[index].path);
}
@override
@@ -92,8 +45,8 @@ class AppShell extends StatelessWidget {
final width = MediaQuery.sizeOf(context).width;
if (width < _narrowBreakpoint) {
final primary = _destinations.where((d) => d.primary).toList();
final selected = _destinations[_selectedIndex];
final primary = navDestinations.where((d) => d.primary).toList();
final selected = navDestinations[_selectedIndex];
final primaryIndex = primary.indexOf(selected);
return Scaffold(
@@ -106,7 +59,7 @@ class AppShell extends StatelessWidget {
if (i >= primary.length) {
_showMore(context, selected);
} else {
final target = _destinations.indexOf(primary[i]);
final target = navDestinations.indexOf(primary[i]);
_onSelect(context, target);
}
},
@@ -131,21 +84,22 @@ class AppShell extends StatelessWidget {
return Scaffold(
body: Row(
children: [
NavigationRail(
extended: extended,
minExtendedWidth: 220,
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => _onSelect(context, i),
labelType: extended ? NavigationRailLabelType.none : NavigationRailLabelType.selected,
destinations: [
for (final d in _destinations)
NavigationRailDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.selectedIcon),
label: Text(d.label),
),
],
),
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),
),
],
),
const VerticalDivider(width: 1),
Expanded(child: SafeArea(child: child)),
],
@@ -155,7 +109,7 @@ class AppShell extends StatelessWidget {
/// The non-primary destinations, as a sheet. The currently open one is ticked, so «Ещё»
/// still answers "where am I".
void _showMore(BuildContext context, _Destination current) {
void _showMore(BuildContext context, NavDestination current) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
@@ -163,7 +117,7 @@ class AppShell extends StatelessWidget {
child: ListView(
shrinkWrap: true,
children: [
for (final d in _destinations.where((d) => !d.primary))
for (final d in navDestinations.where((d) => !d.primary))
ListTile(
leading: Icon(d == current ? d.selectedIcon : d.icon),
title: Text(d.label),