feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
+120
-61
@@ -1,32 +1,40 @@
|
||||
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/core/widgets/service_mark.dart';
|
||||
import 'package:fintracker_app/features/home/providers.dart';
|
||||
import 'package:fintracker_app/features/shell/analytics_tabs.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:fintracker_app/features/shell/top_nav.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',
|
||||
);
|
||||
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 = '/'}) {
|
||||
// The top bar watches meProvider and dataQualityProvider for the profile avatar and the
|
||||
// Здоровье counter — every pump at or above 600 needs both overridden.
|
||||
Widget wrap(
|
||||
double width, {
|
||||
String location = '/',
|
||||
List<DataQualityRow> issues = const [],
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
meProvider.overrideWith((ref) async => UserOut(email: 'ada@example.com', id: 1)),
|
||||
dataQualityProvider.overrideWith((ref) async => const Cached(<DataQualityRow>[])),
|
||||
meProvider.overrideWith(
|
||||
(ref) async => UserOut(email: 'ada@example.com', id: 1),
|
||||
),
|
||||
dataQualityProvider.overrideWith((ref) async => Cached(issues)),
|
||||
],
|
||||
child: MediaQuery(
|
||||
data: MediaQueryData(size: Size(width, 800)),
|
||||
@@ -37,75 +45,126 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('shows the grouped sidebar on a wide surface', (tester) async {
|
||||
testWidgets('shows the top bar on a wide surface', (tester) async {
|
||||
await tester.pumpWidget(wrap(1280));
|
||||
await tester.pump();
|
||||
expect(find.byType(NavSidebar), findsOneWidget);
|
||||
expect(find.byType(TopNav), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsNothing);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
for (final label in navGroupLabels.values) {
|
||||
expect(find.text(label), findsOneWidget);
|
||||
for (final label in [
|
||||
'Обзор',
|
||||
'Аналитика',
|
||||
'Портфель',
|
||||
'Счета',
|
||||
'Операции',
|
||||
'Добавить',
|
||||
]) {
|
||||
expect(find.text(label), findsOneWidget, reason: label);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
testWidgets(
|
||||
'keeps the top bar on a medium surface, with «Добавить» as an icon',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(wrap(700));
|
||||
await tester.pump();
|
||||
expect(find.byType(TopNav), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
expect(find.text('Добавить'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('shows a bottom NavigationBar on a narrow surface', (tester) async {
|
||||
testWidgets('shows a bottom NavigationBar on a narrow surface', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(wrap(400));
|
||||
expect(find.byType(NavigationBar), findsOneWidget);
|
||||
expect(find.byType(NavigationRail), findsNothing);
|
||||
expect(find.byType(NavSidebar), findsNothing);
|
||||
expect(find.byType(TopNav), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the resolve screen keeps Импорт selected', (tester) async {
|
||||
await tester.pumpWidget(wrap(900, location: '/instruments/pending'));
|
||||
final rail = tester.widget<NavigationRail>(find.byType(NavigationRail));
|
||||
expect(
|
||||
(rail.destinations[rail.selectedIndex!].label as Text).data,
|
||||
'Импорт',
|
||||
);
|
||||
await tester.pumpWidget(wrap(1280, location: '/instruments/pending'));
|
||||
await tester.pump();
|
||||
final nav = tester.widget<TopNav>(find.byType(TopNav));
|
||||
expect(navDestinations[nav.selectedIndex].label, 'Импорт');
|
||||
// a ledger screen shows its own name in place of «Операции»
|
||||
expect(find.text('Импорт'), findsOneWidget);
|
||||
expect(find.text('Операции'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a nested route keeps its own section selected', (tester) async {
|
||||
await tester.pumpWidget(wrap(900, location: '/portfolio/instrument/311'));
|
||||
final rail = tester.widget<NavigationRail>(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.pumpWidget(wrap(1280, location: '/portfolio/instrument/311'));
|
||||
await tester.pump();
|
||||
final sidebar = tester.widget<NavSidebar>(find.byType(NavSidebar));
|
||||
expect(navDestinations[sidebar.selectedIndex].label, 'Импорт');
|
||||
final nav = tester.widget<TopNav>(find.byType(TopNav));
|
||||
expect(navDestinations[nav.selectedIndex].label, 'Портфель');
|
||||
});
|
||||
|
||||
testWidgets('the sidebar shows the Здоровье issue count', (tester) async {
|
||||
testWidgets(
|
||||
'the analytics screens share one tab strip and highlight Аналитика',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(wrap(1280, location: '/income'));
|
||||
await tester.pump();
|
||||
final nav = tester.widget<TopNav>(find.byType(TopNav));
|
||||
expect(navDestinations[nav.selectedIndex].label, 'Аналитика');
|
||||
expect(find.byType(AnalyticsTabs), findsOneWidget);
|
||||
for (final label in [
|
||||
'Общее',
|
||||
'Дивиденды',
|
||||
'Ребалансировка',
|
||||
'Цели',
|
||||
'Налоги',
|
||||
'Портфели',
|
||||
]) {
|
||||
expect(find.text(label), findsOneWidget, reason: label);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('other sections have no analytics tab strip', (tester) async {
|
||||
await tester.pumpWidget(wrap(1280, location: '/accounts'));
|
||||
await tester.pump();
|
||||
expect(find.byType(AnalyticsTabs), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the top bar 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())),
|
||||
),
|
||||
),
|
||||
wrap(1280, issues: [_dataQualityRow(1), _dataQualityRow(2)]),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'on a wide monitor the top bar content lines up with the page column',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(2400, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(wrap(2400));
|
||||
await tester.pump();
|
||||
|
||||
// the page column is 1440 wide and centred: it starts at 480 and ends at 1920; the cards
|
||||
// inside have 16 px of padding, and the bar's content must sit on the same edges
|
||||
final logo = tester.getTopLeft(find.byType(ServiceMark));
|
||||
expect(
|
||||
logo.dx,
|
||||
480 + 16,
|
||||
reason: 'the logo starts at the column edge plus the card padding',
|
||||
);
|
||||
expect(tester.getTopRight(find.byType(CircleAvatar)).dx, 1920 - 16);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('on a normal window the top bar keeps the plain 16 px margin', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1200, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(wrap(1200));
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.getTopLeft(find.byType(ServiceMark)).dx, 16);
|
||||
expect(tester.getTopRight(find.byType(CircleAvatar)).dx, 1200 - 16);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user