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('/sync', Icons.sync_outlined, Icons.sync, 'Синк'), _Destination('/settings', Icons.settings_outlined, Icons.settings, 'Настройки'), ]; /// 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 [NavigationRail] (collapsed or /// extended) otherwise. /// /// 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. 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 < _destinations.length; i++) { final length = _destinations[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(_destinations[index].path); } @override Widget build(BuildContext context) { final width = MediaQuery.sizeOf(context).width; if (width < _narrowBreakpoint) { final primary = _destinations.where((d) => d.primary).toList(); final selected = _destinations[_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 = _destinations.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: [ 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), ), ], ), 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, _Destination current) { showModalBottomSheet( context: context, showDragHandle: true, builder: (sheetContext) => SafeArea( child: ListView( shrinkWrap: true, children: [ for (final d in _destinations.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); }, ), ], ), ), ); } }