NavSidebar (>=1200): пункты меню сгруппированы под капс-заголовками, у активного пункта левая акцентная полоска, у «Здоровье» — счётчик замечаний качества данных из dataQualityProvider, внизу закреплён профиль (аватар-инициал + email из meProvider). Список направлений вынесен в nav_destinations.dart — общий для app_shell.dart и nav_sidebar.dart. На 600–1200 остаётся свёрнутый NavigationRail: в иконку-без-подписи заголовки групп и профиль всё равно не помещаются. NavSidebar смонтирован всё время сессии и держит dataQualityProvider и meProvider живыми — раньше эти запросы уходили только при открытии Обзора/Здоровья/Настроек.
137 lines
4.7 KiB
Dart
137 lines
4.7 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
import 'nav_destinations.dart';
|
||
import 'nav_sidebar.dart';
|
||
|
||
/// Breakpoints per the plan: bottom bar under 600, collapsed rail 600–1200,
|
||
/// extended rail at 1200 and above.
|
||
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.
|
||
///
|
||
/// 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});
|
||
|
||
final String location;
|
||
final Widget child;
|
||
|
||
int get _selectedIndex {
|
||
// longest prefix wins, so /portfolio/instrument/311 keeps Портфель selected
|
||
var best = -1;
|
||
var bestLength = -1;
|
||
for (var i = 0; i < navDestinations.length; i++) {
|
||
final length = navDestinations[i].matchLength(location);
|
||
if (length > bestLength) {
|
||
best = i;
|
||
bestLength = length;
|
||
}
|
||
}
|
||
return best == -1 || bestLength < 0 ? 0 : best;
|
||
}
|
||
|
||
void _onSelect(BuildContext context, int index) {
|
||
if (index != _selectedIndex) context.go(navDestinations[index].path);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final width = MediaQuery.sizeOf(context).width;
|
||
|
||
if (width < _narrowBreakpoint) {
|
||
final primary = navDestinations.where((d) => d.primary).toList();
|
||
final selected = navDestinations[_selectedIndex];
|
||
final primaryIndex = primary.indexOf(selected);
|
||
|
||
return Scaffold(
|
||
body: SafeArea(child: child),
|
||
bottomNavigationBar: NavigationBar(
|
||
// nothing primary matches ⇒ we are inside a screen that lives behind «Ещё»,
|
||
// and «Ещё» is what should look active
|
||
selectedIndex: primaryIndex >= 0 ? primaryIndex : primary.length,
|
||
onDestinationSelected: (i) {
|
||
if (i >= primary.length) {
|
||
_showMore(context, selected);
|
||
} else {
|
||
final target = navDestinations.indexOf(primary[i]);
|
||
_onSelect(context, target);
|
||
}
|
||
},
|
||
destinations: [
|
||
for (final d in primary)
|
||
NavigationDestination(
|
||
icon: Icon(d.icon),
|
||
selectedIcon: Icon(d.selectedIcon),
|
||
label: d.label,
|
||
),
|
||
NavigationDestination(
|
||
icon: const Icon(Icons.more_horiz),
|
||
selectedIcon: const Icon(Icons.more_horiz),
|
||
label: primaryIndex >= 0 ? 'Ещё' : selected.label,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
final extended = width >= _wideBreakpoint;
|
||
return Scaffold(
|
||
body: Row(
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
const VerticalDivider(width: 1),
|
||
Expanded(child: SafeArea(child: child)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// The non-primary destinations, as a sheet. The currently open one is ticked, so «Ещё»
|
||
/// still answers "where am I".
|
||
void _showMore(BuildContext context, NavDestination current) {
|
||
showModalBottomSheet<void>(
|
||
context: context,
|
||
showDragHandle: true,
|
||
builder: (sheetContext) => SafeArea(
|
||
child: ListView(
|
||
shrinkWrap: true,
|
||
children: [
|
||
for (final d in navDestinations.where((d) => !d.primary))
|
||
ListTile(
|
||
leading: Icon(d == current ? d.selectedIcon : d.icon),
|
||
title: Text(d.label),
|
||
selected: d == current,
|
||
trailing: d == current ? const Icon(Icons.check) : null,
|
||
onTap: () {
|
||
Navigator.of(sheetContext).pop();
|
||
if (d != current) context.go(d.path);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|