feat(app): группированный сайдбар навигации на широком breakpoint
NavSidebar (>=1200): пункты меню сгруппированы под капс-заголовками, у активного пункта левая акцентная полоска, у «Здоровье» — счётчик замечаний качества данных из dataQualityProvider, внизу закреплён профиль (аватар-инициал + email из meProvider). Список направлений вынесен в nav_destinations.dart — общий для app_shell.dart и nav_sidebar.dart. На 600–1200 остаётся свёрнутый NavigationRail: в иконку-без-подписи заголовки групп и профиль всё равно не помещаются. NavSidebar смонтирован всё время сессии и держит dataQualityProvider и meProvider живыми — раньше эти запросы уходили только при открытии Обзора/Здоровья/Настроек.
This commit is contained in:
@@ -1,55 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class _Destination {
|
import 'nav_destinations.dart';
|
||||||
const _Destination(this.path, this.icon, this.selectedIcon, this.label,
|
import 'nav_sidebar.dart';
|
||||||
{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<String> 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, 'Настройки'),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Breakpoints per the plan: bottom bar under 600, collapsed rail 600–1200,
|
/// Breakpoints per the plan: bottom bar under 600, collapsed rail 600–1200,
|
||||||
/// extended rail at 1200 and above.
|
/// extended rail at 1200 and above.
|
||||||
@@ -57,12 +10,12 @@ const _narrowBreakpoint = 600.0;
|
|||||||
const _wideBreakpoint = 1200.0;
|
const _wideBreakpoint = 1200.0;
|
||||||
|
|
||||||
/// Adaptive navigation shell around the current route: a bottom
|
/// Adaptive navigation shell around the current route: a bottom
|
||||||
/// [NavigationBar] on narrow surfaces, a [NavigationRail] (collapsed or
|
/// [NavigationBar] on narrow surfaces, a collapsed [NavigationRail] on medium
|
||||||
/// extended) otherwise.
|
/// ones, and the grouped [NavSidebar] on wide ones.
|
||||||
///
|
///
|
||||||
/// The rail shows every destination. The bottom bar shows the four primary ones plus
|
/// The rail and the sidebar show every destination. The bottom bar shows the four primary
|
||||||
/// «Ещё», which opens the rest in a sheet: twelve destinations in a phone-width bar would
|
/// ones plus «Ещё», which opens the rest in a sheet: twelve destinations in a phone-width
|
||||||
/// leave about 30 px per label, which is not navigation but decoration.
|
/// bar would leave about 30 px per label, which is not navigation but decoration.
|
||||||
class AppShell extends StatelessWidget {
|
class AppShell extends StatelessWidget {
|
||||||
const AppShell({required this.location, required this.child, super.key});
|
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
|
// longest prefix wins, so /portfolio/instrument/311 keeps Портфель selected
|
||||||
var best = -1;
|
var best = -1;
|
||||||
var bestLength = -1;
|
var bestLength = -1;
|
||||||
for (var i = 0; i < _destinations.length; i++) {
|
for (var i = 0; i < navDestinations.length; i++) {
|
||||||
final length = _destinations[i].matchLength(location);
|
final length = navDestinations[i].matchLength(location);
|
||||||
if (length > bestLength) {
|
if (length > bestLength) {
|
||||||
best = i;
|
best = i;
|
||||||
bestLength = length;
|
bestLength = length;
|
||||||
@@ -84,7 +37,7 @@ class AppShell extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onSelect(BuildContext context, int index) {
|
void _onSelect(BuildContext context, int index) {
|
||||||
if (index != _selectedIndex) context.go(_destinations[index].path);
|
if (index != _selectedIndex) context.go(navDestinations[index].path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -92,8 +45,8 @@ class AppShell extends StatelessWidget {
|
|||||||
final width = MediaQuery.sizeOf(context).width;
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
|
||||||
if (width < _narrowBreakpoint) {
|
if (width < _narrowBreakpoint) {
|
||||||
final primary = _destinations.where((d) => d.primary).toList();
|
final primary = navDestinations.where((d) => d.primary).toList();
|
||||||
final selected = _destinations[_selectedIndex];
|
final selected = navDestinations[_selectedIndex];
|
||||||
final primaryIndex = primary.indexOf(selected);
|
final primaryIndex = primary.indexOf(selected);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -106,7 +59,7 @@ class AppShell extends StatelessWidget {
|
|||||||
if (i >= primary.length) {
|
if (i >= primary.length) {
|
||||||
_showMore(context, selected);
|
_showMore(context, selected);
|
||||||
} else {
|
} else {
|
||||||
final target = _destinations.indexOf(primary[i]);
|
final target = navDestinations.indexOf(primary[i]);
|
||||||
_onSelect(context, target);
|
_onSelect(context, target);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -131,21 +84,22 @@ class AppShell extends StatelessWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
NavigationRail(
|
if (extended)
|
||||||
extended: extended,
|
NavSidebar(selectedIndex: _selectedIndex, onSelect: (i) => _onSelect(context, i))
|
||||||
minExtendedWidth: 220,
|
else
|
||||||
selectedIndex: _selectedIndex,
|
NavigationRail(
|
||||||
onDestinationSelected: (i) => _onSelect(context, i),
|
selectedIndex: _selectedIndex,
|
||||||
labelType: extended ? NavigationRailLabelType.none : NavigationRailLabelType.selected,
|
onDestinationSelected: (i) => _onSelect(context, i),
|
||||||
destinations: [
|
labelType: NavigationRailLabelType.selected,
|
||||||
for (final d in _destinations)
|
destinations: [
|
||||||
NavigationRailDestination(
|
for (final d in navDestinations)
|
||||||
icon: Icon(d.icon),
|
NavigationRailDestination(
|
||||||
selectedIcon: Icon(d.selectedIcon),
|
icon: Icon(d.icon),
|
||||||
label: Text(d.label),
|
selectedIcon: Icon(d.selectedIcon),
|
||||||
),
|
label: Text(d.label),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
const VerticalDivider(width: 1),
|
const VerticalDivider(width: 1),
|
||||||
Expanded(child: SafeArea(child: child)),
|
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 «Ещё»
|
/// The non-primary destinations, as a sheet. The currently open one is ticked, so «Ещё»
|
||||||
/// still answers "where am I".
|
/// still answers "where am I".
|
||||||
void _showMore(BuildContext context, _Destination current) {
|
void _showMore(BuildContext context, NavDestination current) {
|
||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
@@ -163,7 +117,7 @@ class AppShell extends StatelessWidget {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
children: [
|
children: [
|
||||||
for (final d in _destinations.where((d) => !d.primary))
|
for (final d in navDestinations.where((d) => !d.primary))
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(d == current ? d.selectedIcon : d.icon),
|
leading: Icon(d == current ? d.selectedIcon : d.icon),
|
||||||
title: Text(d.label),
|
title: Text(d.label),
|
||||||
|
|||||||
@@ -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<String> 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),
|
||||||
|
];
|
||||||
@@ -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<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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/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/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.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() {
|
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 = '/'}) {
|
Widget wrap(double width, {String location = '/'}) {
|
||||||
return MediaQuery(
|
return ProviderScope(
|
||||||
data: MediaQueryData(size: Size(width, 800)),
|
overrides: [
|
||||||
child: MaterialApp(
|
meProvider.overrideWith((ref) async => UserOut(email: 'ada@example.com', id: 1)),
|
||||||
home: AppShell(location: location, child: Container()),
|
dataQualityProvider.overrideWith((ref) async => const Cached(<DataQualityRow>[])),
|
||||||
|
],
|
||||||
|
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.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(NavigationRail), findsOneWidget);
|
||||||
|
expect(find.byType(NavSidebar), findsNothing);
|
||||||
expect(find.byType(NavigationBar), findsNothing);
|
expect(find.byType(NavigationBar), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,10 +59,11 @@ void main() {
|
|||||||
await tester.pumpWidget(wrap(400));
|
await tester.pumpWidget(wrap(400));
|
||||||
expect(find.byType(NavigationBar), findsOneWidget);
|
expect(find.byType(NavigationBar), findsOneWidget);
|
||||||
expect(find.byType(NavigationRail), findsNothing);
|
expect(find.byType(NavigationRail), findsNothing);
|
||||||
|
expect(find.byType(NavSidebar), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('the resolve screen keeps Импорт selected', (tester) async {
|
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<NavigationRail>(find.byType(NavigationRail));
|
final rail = tester.widget<NavigationRail>(find.byType(NavigationRail));
|
||||||
expect(
|
expect(
|
||||||
(rail.destinations[rail.selectedIndex!].label as Text).data,
|
(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 {
|
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<NavigationRail>(find.byType(NavigationRail));
|
final rail = tester.widget<NavigationRail>(find.byType(NavigationRail));
|
||||||
expect(
|
expect(
|
||||||
(rail.destinations[rail.selectedIndex!].label as Text).data,
|
(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<NavSidebar>(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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user