Позиции и аллокация сделаны вкладками одного экрана, а не двумя пунктами навигации: они отвечают на две половины одного вопроса, и десятый пункт в bottom bar оставил бы по сорок пикселей на подпись. Scope переключается один раз и сразу для всех трёх экранов — три экрана с разными scope были бы ловушкой. Позиция без цены показывается прочерком, никогда нулём: её стоимость не входит в итоги выше, и 0 ₽ читался бы как «ничего не стоит» вместо «неизвестно». То же для общей прибыли, когда в портфеле есть хоть одна неоценённая бумага. Подписи ключей берутся по .value, а не по .name: генератор придумывает Dart-имя (assetClass для asset_class), и словарь, ключёванный по .name, молча не совпадает никогда. Тесты пампят экран на высокой поверхности: вкладки это длинные списки, а ListView строит только видимое, и на дефолтных 800x600 таблица позиций просто не существует.
102 lines
3.6 KiB
Dart
102 lines
3.6 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
class _Destination {
|
||
const _Destination(this.path, this.icon, this.selectedIcon, this.label);
|
||
final String path;
|
||
final IconData icon;
|
||
final IconData selectedIcon;
|
||
final String label;
|
||
}
|
||
|
||
const _destinations = [
|
||
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор'),
|
||
_Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета'),
|
||
_Destination('/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель'),
|
||
_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.
|
||
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;
|
||
for (var i = 0; i < _destinations.length; i++) {
|
||
final path = _destinations[i].path;
|
||
final matches = path == '/' ? location == '/' : location.startsWith(path);
|
||
if (matches && (best == -1 || path.length > _destinations[best].path.length)) best = i;
|
||
}
|
||
return best == -1 ? 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) {
|
||
return Scaffold(
|
||
body: SafeArea(child: child),
|
||
bottomNavigationBar: NavigationBar(
|
||
selectedIndex: _selectedIndex,
|
||
onDestinationSelected: (i) => _onSelect(context, i),
|
||
destinations: [
|
||
for (final d in _destinations)
|
||
NavigationDestination(
|
||
icon: Icon(d.icon),
|
||
selectedIcon: Icon(d.selectedIcon),
|
||
label: d.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)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|