feat(app): новый визуальный язык, верхняя навигация, портфели, создание счетов и ручные события
Тема и общие виджеты (AssetIcon, ServiceMark, HelpTip, глоссарий), верхняя панель TopNav и вкладки Аналитики вместо NavSidebar, иконки PWA. Экраны: портфели, создание брокерского счёта, ручное событие, правка инструмента, Обзор карточками по скоупам и таблица активов, пересчёт метрик с опросом /metrics/status. design-system.md обновлён.
This commit is contained in:
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user