diff --git a/app/lib/features/shell/app_shell.dart b/app/lib/features/shell/app_shell.dart index ef1c659..9db9f8a 100644 --- a/app/lib/features/shell/app_shell.dart +++ b/app/lib/features/shell/app_shell.dart @@ -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 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 600–1200, /// 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( 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), diff --git a/app/lib/features/shell/nav_destinations.dart b/app/lib/features/shell/nav_destinations.dart new file mode 100644 index 0000000..6ccb4f7 --- /dev/null +++ b/app/lib/features/shell/nav_destinations.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +/// The three groups the sidebar buckets destinations under (Grimmory-style caps headers). +/// Order here is display order, top to bottom. +enum NavGroup { overview, ledger, system } + +const navGroupLabels = { + NavGroup.overview: 'ОБЗОР', + NavGroup.ledger: 'ОПЕРАЦИИ', + NavGroup.system: 'СИСТЕМА', +}; + +class NavDestination { + const NavDestination(this.path, this.icon, this.selectedIcon, this.label, this.group, + {this.alsoMatches = const [], this.primary = false}); + final String path; + final IconData icon; + final IconData selectedIcon; + final String label; + final NavGroup group; + + /// 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 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 navDestinations = [ + NavDestination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор', NavGroup.overview, + primary: true), + NavDestination( + '/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета', NavGroup.overview, + primary: true), + NavDestination( + '/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель', NavGroup.overview, + 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. + NavDestination('/analytics', Icons.insights_outlined, Icons.insights, 'Аналитика', NavGroup.overview, + alsoMatches: ['/income', '/rebalance', '/goals', '/tax'], primary: true), + NavDestination('/events', Icons.event_note_outlined, Icons.event_note, 'События', NavGroup.ledger), + NavDestination('/imports', Icons.upload_file_outlined, Icons.upload_file, 'Импорт', NavGroup.ledger, + alsoMatches: ['/instruments/pending']), + NavDestination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки', NavGroup.ledger), + NavDestination('/categories', Icons.category_outlined, Icons.category, 'Категории', NavGroup.ledger), + NavDestination( + '/transactions', Icons.receipt_long_outlined, Icons.receipt_long, 'Операции', NavGroup.ledger), + NavDestination('/rules', Icons.rule_folder_outlined, Icons.rule_folder, 'Правила', NavGroup.ledger), + NavDestination( + '/health', Icons.monitor_heart_outlined, Icons.monitor_heart, 'Здоровье', NavGroup.system), + NavDestination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки', NavGroup.system), +]; diff --git a/app/lib/features/shell/nav_sidebar.dart b/app/lib/features/shell/nav_sidebar.dart new file mode 100644 index 0000000..e47d0df --- /dev/null +++ b/app/lib/features/shell/nav_sidebar.dart @@ -0,0 +1,199 @@ +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 onSelect; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final issueCount = ref.watch(dataQualityProvider).valueOrNull?.data.length; + + final byGroup = >{}; + 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 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), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/app/test/app_shell_test.dart b/app/test/app_shell_test.dart index b69222e..f8c0008 100644 --- a/app/test/app_shell_test.dart +++ b/app/test/app_shell_test.dart @@ -1,20 +1,57 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:fintracker_app/core/api/common_providers.dart'; +import 'package:fintracker_app/core/cache/cached.dart'; +import 'package:fintracker_app/features/home/providers.dart'; import 'package:fintracker_app/features/shell/app_shell.dart'; +import 'package:fintracker_app/features/shell/nav_destinations.dart'; +import 'package:fintracker_app/features/shell/nav_sidebar.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +DataQualityRow _dataQualityRow(int id) => DataQualityRow( + checkName: 'x', + computedAt: DateTime(2026, 9, 19), + count: 1, + detail: 'issue $id', + id: id, + ref: null, + severity: 'warning', + ); + void main() { + // The sidebar (>=1200) watches meProvider and dataQualityProvider for the profile + // footer and the Здоровье counter — every wide-breakpoint pump needs both overridden. Widget wrap(double width, {String location = '/'}) { - return MediaQuery( - data: MediaQueryData(size: Size(width, 800)), - child: MaterialApp( - home: AppShell(location: location, child: Container()), + return ProviderScope( + overrides: [ + meProvider.overrideWith((ref) async => UserOut(email: 'ada@example.com', id: 1)), + dataQualityProvider.overrideWith((ref) async => const Cached([])), + ], + child: MediaQuery( + data: MediaQueryData(size: Size(width, 800)), + child: MaterialApp( + home: AppShell(location: location, child: Container()), + ), ), ); } - testWidgets('shows a NavigationRail on a wide surface', (tester) async { + testWidgets('shows the grouped sidebar on a wide surface', (tester) async { await tester.pumpWidget(wrap(1280)); + await tester.pump(); + expect(find.byType(NavSidebar), findsOneWidget); + expect(find.byType(NavigationRail), findsNothing); + expect(find.byType(NavigationBar), findsNothing); + for (final label in navGroupLabels.values) { + expect(find.text(label), findsOneWidget); + } + }); + + testWidgets('shows a collapsed NavigationRail on a medium surface', (tester) async { + await tester.pumpWidget(wrap(900)); expect(find.byType(NavigationRail), findsOneWidget); + expect(find.byType(NavSidebar), findsNothing); expect(find.byType(NavigationBar), findsNothing); }); @@ -22,10 +59,11 @@ void main() { await tester.pumpWidget(wrap(400)); expect(find.byType(NavigationBar), findsOneWidget); expect(find.byType(NavigationRail), findsNothing); + expect(find.byType(NavSidebar), findsNothing); }); testWidgets('the resolve screen keeps Импорт selected', (tester) async { - await tester.pumpWidget(wrap(1280, location: '/instruments/pending')); + await tester.pumpWidget(wrap(900, location: '/instruments/pending')); final rail = tester.widget(find.byType(NavigationRail)); expect( (rail.destinations[rail.selectedIndex!].label as Text).data, @@ -34,11 +72,40 @@ void main() { }); testWidgets('a nested route keeps its own section selected', (tester) async { - await tester.pumpWidget(wrap(1280, location: '/portfolio/instrument/311')); + await tester.pumpWidget(wrap(900, location: '/portfolio/instrument/311')); final rail = tester.widget(find.byType(NavigationRail)); expect( (rail.destinations[rail.selectedIndex!].label as Text).data, 'Портфель', ); }); + + testWidgets('the sidebar keeps Импорт selected on the resolve screen', (tester) async { + await tester.pumpWidget(wrap(1280, location: '/instruments/pending')); + await tester.pump(); + final sidebar = tester.widget(find.byType(NavSidebar)); + expect(navDestinations[sidebar.selectedIndex].label, 'Импорт'); + }); + + testWidgets('the sidebar shows the Здоровье issue count', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + meProvider.overrideWith((ref) async => UserOut(email: 'ada@example.com', id: 1)), + dataQualityProvider.overrideWith( + (ref) async => Cached([ + _dataQualityRow(1), + _dataQualityRow(2), + ]), + ), + ], + child: MediaQuery( + data: const MediaQueryData(size: Size(1280, 800)), + child: MaterialApp(home: AppShell(location: '/', child: Container())), + ), + ), + ); + await tester.pump(); + expect(find.text('2'), findsOneWidget); + }); }