Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
142 lines
4.8 KiB
Dart
142 lines
4.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import 'analytics_tabs.dart';
|
|
import 'nav_destinations.dart';
|
|
import 'top_nav.dart';
|
|
|
|
/// Below this width the shell falls back to a bottom [NavigationBar]; from here up it is the
|
|
/// [TopNav] bar.
|
|
const _narrowBreakpoint = 600.0;
|
|
|
|
/// Adaptive navigation shell around the current route: a bottom [NavigationBar] on narrow
|
|
/// surfaces and the [TopNav] bar on wider ones, with the Аналитика tab strip under it while
|
|
/// one of the analytics screens is open.
|
|
///
|
|
/// The top bar shows every destination (the ledger ones behind «Операции»). 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 < navDestinations.length; i++) {
|
|
final length = navDestinations[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(navDestinations[index].path);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final width = MediaQuery.sizeOf(context).width;
|
|
|
|
if (width < _narrowBreakpoint) {
|
|
final primary = navDestinations.where((d) => d.primary).toList();
|
|
final selected = navDestinations[_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 = navDestinations.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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
body: Column(
|
|
children: [
|
|
TopNav(
|
|
selectedIndex: _selectedIndex,
|
|
onSelect: (i) => _onSelect(context, i),
|
|
),
|
|
Expanded(
|
|
child: Align(
|
|
alignment: Alignment.topCenter,
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: kContentMaxWidth),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (AnalyticsTabs.contains(location))
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
|
child: AnalyticsTabs(location: location),
|
|
),
|
|
Expanded(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, NavDestination current) {
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
showDragHandle: true,
|
|
builder: (sheetContext) => SafeArea(
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
for (final d in navDestinations.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);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|