feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события

Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
Dmitry
2026-09-19 22:14:21 +03:00
parent a559d6de3e
commit 62d36aa3e8
73 changed files with 6406 additions and 1192 deletions
+258
View File
@@ -0,0 +1,258 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fintracker_app/core/cache/cached.dart';
import 'package:fintracker_app/core/widgets/money_text.dart';
import 'package:fintracker_app/features/accounts/accounts_page.dart';
import 'package:fintracker_app/features/accounts/actions.dart';
import 'package:fintracker_app/features/accounts/providers.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
AccountOut _account(
int id,
String name,
AccountKind kind, {
String? balance,
String? valueRub,
bool disabled = false,
bool archived = false,
AccountRole? role,
String source = 'zenmoney',
}) => AccountOut(
id: id,
kind: kind,
source_: source,
sourceId: 'acc-$id',
broker: null,
name: name,
currency: 'RUB',
role:
role ??
(kind == AccountKind.broker
? AccountRole.investment
: AccountRole.liquid),
includeInNetWorth: true,
mirrorOfAccountId: null,
primaryEventSource: null,
archived: archived,
disabled: disabled,
openedAt: null,
balance: balance,
balanceAsOf: null,
startBalance: null,
creditLimit: null,
valueRub: valueRub,
);
class _FakeActions implements AccountActions {
final calls = <(List<int>, AccountPatch)>[];
@override
Future<String?> patch(Iterable<int> ids, AccountPatch patch) async {
calls.add((ids.toList(), patch));
return null;
}
}
/// Rows sort as: active by type then name, then disabled — so «Карта» (1), «Брокер» (3),
/// «Старая карта» (2, disabled).
List<AccountOut> _sample() => [
_account(1, 'Карта', AccountKind.zmCard, balance: '500'),
_account(
2,
'Старая карта',
AccountKind.zmCard,
balance: '20',
disabled: true,
),
_account(
3,
'Брокер',
AccountKind.broker,
valueRub: '1044264',
source: 'tinvest',
),
];
Future<_FakeActions> _open(
WidgetTester tester,
List<AccountOut> accounts, {
double width = 1200,
}) async {
tester.view.physicalSize = Size(width, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final fake = _FakeActions();
await tester.pumpWidget(
ProviderScope(
overrides: [
accountsProvider.overrideWith((ref) async => Cached(accounts)),
accountActionsProvider.overrideWithValue(fake),
],
child: const MaterialApp(home: AccountsPage()),
),
);
await tester.pumpAndSettle();
return fake;
}
void main() {
testWidgets(
'a broker account shows its ledger valuation, a card its balance',
(tester) async {
await _open(tester, _sample());
expect(find.text(MoneyText.format('1044264', 'RUB')), findsOneWidget);
expect(find.text(MoneyText.format('500', 'RUB')), findsOneWidget);
// the source tells a ZenMoney account from a broker one
expect(find.textContaining('T-Invest'), findsOneWidget);
expect(find.textContaining('ZenMoney'), findsWidgets);
},
);
testWidgets('no amount at all reads as a dash, not as zero', (tester) async {
await _open(tester, [_account(1, 'Пустой брокер', AccountKind.broker)]);
expect(find.text(''), findsOneWidget);
});
testWidgets('the status chips count the accounts and filter the list', (
tester,
) async {
await _open(tester, _sample());
expect(find.text('Все 3'), findsOneWidget);
expect(find.text('Активные 2'), findsOneWidget);
expect(find.text('Отключённые 1'), findsOneWidget);
expect(find.text('Архивные 0'), findsOneWidget);
expect(
find.text('Старая карта'),
findsOneWidget,
reason: 'all shows the disabled one too',
);
await tester.tap(find.text('Отключённые 1'));
await tester.pumpAndSettle();
expect(find.text('Старая карта'), findsOneWidget);
expect(find.text('Карта'), findsNothing);
expect(find.text('Брокер'), findsNothing);
});
testWidgets('the type chips and the search narrow the list', (tester) async {
await _open(tester, _sample());
await tester.tap(find.widgetWithText(FilterChip, 'Инвестиции'));
await tester.pumpAndSettle();
expect(find.text('Брокер'), findsOneWidget);
expect(find.text('Карта'), findsNothing);
await tester.tap(find.widgetWithText(FilterChip, 'Инвестиции'));
await tester.enterText(find.byType(TextField), 'старая');
await tester.pumpAndSettle();
expect(find.text('Старая карта'), findsOneWidget);
expect(find.text('Брокер'), findsNothing);
await tester.enterText(find.byType(TextField), 'nothing like it');
await tester.pumpAndSettle();
expect(find.text('Ничего не найдено'), findsOneWidget);
});
testWidgets(
'the «Активен» switch on a row sends one patch for that account',
(tester) async {
final fake = await _open(tester, _sample());
// per row the switches are «В капитал» then «Активен»; the first row is «Карта» (id 1)
await tester.tap(find.byType(Switch).at(1));
await tester.pumpAndSettle();
expect(fake.calls, hasLength(1));
expect(fake.calls.single.$1, [1]);
expect(fake.calls.single.$2.disabled, isTrue);
},
);
testWidgets('switching a disabled account back on sends disabled: false', (
tester,
) async {
final fake = await _open(tester, _sample());
await tester.tap(find.byType(Switch).at(5)); // «Старая карта», «Активен»
await tester.pumpAndSettle();
expect(fake.calls.single.$1, [2]);
expect(fake.calls.single.$2.disabled, isFalse);
});
testWidgets('the type pill offers the four types and sends the chosen one', (
tester,
) async {
final fake = await _open(tester, _sample());
await tester.tap(find.byTooltip('Тип счёта').first);
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(CheckedPopupMenuItem<AccountRole>, 'Сбережения'),
);
await tester.pumpAndSettle();
expect(fake.calls.single.$1, [1]);
expect(fake.calls.single.$2.role, AccountRole.savings);
});
testWidgets(
'a selection turns into bulk actions and clears itself afterwards',
(tester) async {
final fake = await _open(tester, _sample());
// checkbox 0 is «select all»; rows follow in list order: Карта (1), Брокер (3)
await tester.tap(find.byType(Checkbox).at(1));
await tester.tap(find.byType(Checkbox).at(2));
await tester.pumpAndSettle();
expect(find.text('Выбрано: 2'), findsOneWidget);
await tester.tap(find.text('Отключить'));
await tester.pumpAndSettle();
expect(fake.calls.single.$1, unorderedEquals([1, 3]));
expect(fake.calls.single.$2.disabled, isTrue);
expect(
find.textContaining('Выбрано'),
findsNothing,
reason: 'the selection is spent',
);
},
);
testWidgets('«select all» takes exactly what the filter shows', (
tester,
) async {
final fake = await _open(tester, _sample());
await tester.tap(find.text('Отключённые 1'));
await tester.pumpAndSettle();
await tester.tap(find.byType(Checkbox).first);
await tester.pumpAndSettle();
expect(find.text('Выбрано: 1'), findsOneWidget);
await tester.tap(find.text('Включить'));
await tester.pumpAndSettle();
expect(fake.calls.single.$1, [2]);
expect(fake.calls.single.$2.disabled, isFalse);
});
testWidgets(
'a narrow window puts the controls on a second line with their labels',
(tester) async {
await _open(tester, _sample(), width: 500);
expect(find.text('Активен'), findsNWidgets(3));
expect(find.text('В капитал'), findsNWidgets(3));
expect(
find.text('Баланс / стоимость'),
findsNothing,
reason: 'no table header on a phone',
);
},
);
}