NavSidebar (>=1200): пункты меню сгруппированы под капс-заголовками, у активного пункта левая акцентная полоска, у «Здоровье» — счётчик замечаний качества данных из dataQualityProvider, внизу закреплён профиль (аватар-инициал + email из meProvider). Список направлений вынесен в nav_destinations.dart — общий для app_shell.dart и nav_sidebar.dart. На 600–1200 остаётся свёрнутый NavigationRail: в иконку-без-подписи заголовки групп и профиль всё равно не помещаются. NavSidebar смонтирован всё время сессии и держит dataQualityProvider и meProvider живыми — раньше эти запросы уходили только при открытии Обзора/Здоровья/Настроек.
200 lines
6.6 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|