Files
fin-tracker/app/lib/features/shell/nav_sidebar.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

200 lines
6.6 KiB
Dart

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),
),
],
),
),
],
),
);
}
}