Files
fin-tracker/app/lib/features/shell/app_shell.dart
T
Dmitry 8d404ce7d3 feat(app): группированный сайдбар навигации на широком breakpoint
NavSidebar (>=1200): пункты меню сгруппированы под капс-заголовками,
у активного пункта левая акцентная полоска, у «Здоровье» — счётчик
замечаний качества данных из dataQualityProvider, внизу закреплён
профиль (аватар-инициал + email из meProvider). Список направлений
вынесен в nav_destinations.dart — общий для app_shell.dart и
nav_sidebar.dart. На 600–1200 остаётся свёрнутый NavigationRail: в
иконку-без-подписи заголовки групп и профиль всё равно не помещаются.

NavSidebar смонтирован всё время сессии и держит dataQualityProvider
и meProvider живыми — раньше эти запросы уходили только при открытии
Обзора/Здоровья/Настроек.
2026-09-19 15:31:37 +03:00

137 lines
4.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
},
),
],
),
),
);
}
}