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
+56
View File
@@ -0,0 +1,56 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fintracker_app/features/accounts/account_create_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _host({Broker? broker, String? sourceId, String? name}) => ProviderScope(
child: MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () => showAccountCreateDialog(
context,
broker: broker,
sourceId: sourceId,
name: name,
),
child: const Text('open'),
),
),
),
),
);
void main() {
testWidgets('the dialog starts from what the report says', (tester) async {
await tester.pumpWidget(
_host(broker: Broker.vtb, sourceId: 'BR-42', name: 'ВТБ BR-42'),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Новый брокерский счёт'), findsOneWidget);
expect(find.text('BR-42'), findsOneWidget);
expect(find.text('ВТБ BR-42'), findsOneWidget);
expect(find.text('ВТБ'), findsOneWidget); // the selected broker
});
testWidgets('name and agreement number are required', (tester) async {
await tester.pumpWidget(_host());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.tap(find.text('Создать'));
await tester.pumpAndSettle();
expect(find.text('Обязательное поле'), findsNWidgets(2));
});
test('import broker keys map onto creatable brokers', () {
expect(brokerFromImportKey('sber'), Broker.sber);
expect(brokerFromImportKey('vtb'), Broker.vtb);
expect(brokerFromImportKey('csv'), Broker.other);
expect(brokerFromImportKey('tinvest'), isNull);
});
}
+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',
);
},
);
}
+120 -61
View File
@@ -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);
});
}
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fintracker_app/core/widgets/asset_icon.dart';
Future<void> _pump(WidgetTester tester, Widget icon) => tester.pumpWidget(
MaterialApp(
home: Scaffold(body: Center(child: icon)),
),
);
void main() {
test('parseBrandColor reads #RRGGBB and #AARRGGBB, and refuses the rest', () {
expect(parseBrandColor('#21A038'), const Color(0xFF21A038));
expect(parseBrandColor('80FF0000'), const Color(0x80FF0000));
expect(parseBrandColor(null), isNull);
expect(parseBrandColor('#12'), isNull);
expect(parseBrandColor('#GGGGGG'), isNull);
});
testWidgets('without a logo the class icon shows on the brand colour', (
tester,
) async {
await _pump(
tester,
const AssetIcon(assetClass: 'bond', logoColor: '#21A038'),
);
expect(find.byType(Image), findsNothing);
final icon = tester.widget<Icon>(find.byType(Icon));
expect(icon.icon, assetClassIcon('bond'));
expect(icon.color, Colors.white); // dark green → light glyph
final fill = tester.widget<ColoredBox>(find.byType(ColoredBox).last);
expect(fill.color, const Color(0xFF21A038));
});
testWidgets(
'with a logo an image is requested, and a failed load falls back',
(tester) async {
await _pump(
tester,
const AssetIcon(
assetClass: 'share',
logoUrl: 'http://127.0.0.1:1/none.png',
),
);
expect(find.byType(Image), findsOneWidget);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 200)),
);
await tester.pump();
expect(find.byIcon(assetClassIcon('share')), findsOneWidget);
},
);
test('every asset class has its own icon, unknown ones a neutral one', () {
expect(assetClassIcon('share'), isNot(assetClassIcon('bond')));
expect(assetClassIcon('something-new'), Icons.circle_outlined);
});
}
+121
View File
@@ -0,0 +1,121 @@
import 'package:fintracker_app/core/glossary.dart';
import 'package:fintracker_app/core/widgets/help_tip.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _app(Widget child) => MaterialApp(
home: Scaffold(body: Center(child: child)),
);
void main() {
test('the glossary finds a term regardless of case, spaces and «ё»', () {
expect(glossaryHint('XIRR'), isNotNull);
expect(glossaryHint(' xirr '), glossaryHint('XIRR'));
expect(glossaryHint('Запас хода (runway)'), isNotNull);
expect(glossaryHint('Внешний поток'), isNotNull);
expect(
glossaryHint('Счёт'),
isNull,
reason: 'an ordinary word is not a term',
);
});
test('every explanation is a real sentence, not a stub', () {
for (final label in [
'XIRR',
'TWR',
'НКД',
'ЛДВ',
'Пассивный доход',
'Запас хода (runway)',
]) {
final text = glossaryHint(label)!;
expect(text.length, greaterThan(40), reason: label);
expect(text.trim().endsWith('.'), isTrue, reason: label);
}
});
testWidgets('a known term carries a «?», an unknown label stays plain text', (
tester,
) async {
await tester.pumpWidget(
_app(
const Column(
mainAxisSize: MainAxisSize.min,
children: [TermLabel('TWR'), TermLabel('Название')],
),
),
);
expect(find.text('TWR'), findsOneWidget);
expect(find.text('Название'), findsOneWidget);
expect(find.byIcon(Icons.help_outline), findsOneWidget);
});
testWidgets('an explicit hint wins over the glossary', (tester) async {
await tester.pumpWidget(
_app(const TermLabel('Прибыль', hint: 'Своё пояснение для налогов.')),
);
final tip = tester.widget<Tooltip>(find.byType(Tooltip));
expect(tip.message, 'Своё пояснение для налогов.');
});
testWidgets('hovering the «?» with a mouse shows the explanation', (
tester,
) async {
await tester.pumpWidget(_app(const TermLabel('XIRR')));
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: Offset.zero);
addTearDown(mouse.removePointer);
await mouse.moveTo(tester.getCenter(find.byIcon(Icons.help_outline)));
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
expect(
find.textContaining('годовая доходность с учётом дат'),
findsOneWidget,
);
});
testWidgets('a tap shows it too, for a phone with no hover', (tester) async {
await tester.pumpWidget(_app(const TermLabel('НКД')));
await tester.tap(find.byIcon(Icons.help_outline));
await tester.pump(const Duration(milliseconds: 300));
expect(find.textContaining('накопленный купонный доход'), findsOneWidget);
});
testWidgets('a numeric header keeps its label at the end of the cell', (
tester,
) async {
await tester.pumpWidget(
_app(
const SizedBox(
width: 200,
child: TermLabel(
'XIRR',
alignment: MainAxisAlignment.end,
textAlign: TextAlign.end,
),
),
),
);
final label = tester.getRect(find.text('XIRR'));
final icon = tester.getRect(find.byIcon(Icons.help_outline));
expect(
icon.left,
greaterThanOrEqualTo(label.right),
reason: 'the «?» follows the text',
);
expect(
icon.right,
greaterThan(150),
reason: 'and the pair hugs the right edge',
);
});
}
+63 -35
View File
@@ -8,41 +8,63 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
NetWorthBreakdown _breakdown() => NetWorthBreakdown(
accounts: const [],
byCurrency: const {'RUB': '123456.78'},
d: DateTime(2026, 9, 17),
debtRub: '1000',
investmentRub: '2000',
liquidRub: '3000',
missingFxCount: 0,
savingsRub: '4000',
totalRub: '123456.78',
);
accounts: const [],
byCurrency: const {'RUB': '123456.78'},
d: DateTime(2026, 9, 17),
debtRub: '1000',
investmentRub: '2000',
liquidRub: '3000',
missingFxCount: 0,
savingsRub: '4000',
totalRub: '123456.78',
);
RunwayOut _runway() => RunwayOut(
asOf: DateTime(2026, 9, 17),
avgBaseline3mRub: '0',
liquidReserveRub: '0',
runwayMonths: null,
);
asOf: DateTime(2026, 9, 17),
avgBaseline3mRub: '0',
liquidReserveRub: '0',
runwayMonths: null,
);
// Every provider HomePage watches needs an override — including
// portfolioSummaryHomeProvider, easy to forget since it renders nothing on an
// empty ledger. Without it, HomePage falls through to the real apiProvider,
// which now also opens a real ResponseCacheDatabase (see docs/ai/offline-cache.md).
List<Override> _overrides({DateTime? staleAt}) => [
netWorthBreakdownProvider.overrideWith((ref) => Cached(_breakdown(), fetchedAt: staleAt)),
netWorthSeriesProvider.overrideWith((ref) => Cached(const <NetWorthDay>[], fetchedAt: staleAt)),
cashflowThisMonthProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)),
cashflowLast12Provider.overrideWith((ref) => Cached(const <CashFlowMonth>[], fetchedAt: staleAt)),
runwayProvider.overrideWith((ref) => Cached(_runway(), fetchedAt: staleAt)),
metricsStatusProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)),
dataQualityProvider.overrideWith((ref) => Cached(const <DataQualityRow>[], fetchedAt: staleAt)),
portfolioSummaryHomeProvider.overrideWith((ref) => Cached(null, fetchedAt: staleAt)),
];
netWorthBreakdownProvider.overrideWith(
(ref) => Cached(_breakdown(), fetchedAt: staleAt),
),
netWorthSeriesProvider.overrideWith(
(ref) => Cached(const <NetWorthDay>[], fetchedAt: staleAt),
),
cashflowThisMonthProvider.overrideWith(
(ref) => Cached(null, fetchedAt: staleAt),
),
cashflowLast12Provider.overrideWith(
(ref) => Cached(const <CashFlowMonth>[], fetchedAt: staleAt),
),
runwayProvider.overrideWith((ref) => Cached(_runway(), fetchedAt: staleAt)),
metricsStatusProvider.overrideWith(
(ref) => Cached(
MetricsStatusOut(lastRefresh: null, consistent: true, refreshing: false),
fetchedAt: staleAt,
),
),
dataQualityProvider.overrideWith(
(ref) => Cached(const <DataQualityRow>[], fetchedAt: staleAt),
),
portfolioSummaryHomeProvider.overrideWith(
(ref) => Cached(null, fetchedAt: staleAt),
),
scopeCardsProvider.overrideWith(
(ref) => Cached(const <ScopeCardOut>[], fetchedAt: staleAt),
),
];
void main() {
testWidgets('Обзор renders stat tiles from a net worth breakdown', (tester) async {
testWidgets('Обзор renders stat tiles from a net worth breakdown', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: _overrides(),
@@ -60,15 +82,21 @@ void main() {
expect(find.textContaining('Нет соединения'), findsNothing);
});
testWidgets('shows the offline banner when the dashboard came from the cache', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: _overrides(staleAt: DateTime(2026, 9, 10, 8, 30)),
child: const MaterialApp(home: HomePage()),
),
);
await tester.pumpAndSettle();
testWidgets(
'shows the offline banner when the dashboard came from the cache',
(tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: _overrides(staleAt: DateTime(2026, 9, 10, 8, 30)),
child: const MaterialApp(home: HomePage()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Нет соединения — данные на 10.09.2026 08:30'), findsOneWidget);
});
expect(
find.textContaining('Нет соединения — данные на 10.09.2026 08:30'),
findsOneWidget,
);
},
);
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fintracker_app/features/portfolio/instrument_edit_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
InstrumentOut _instrument() => InstrumentOut(
id: 1,
assetClass: 'share',
isin: 'RU0008958863',
figi: null,
ticker: 'MSNG',
board: 'TQBR',
exchange: null,
name: 'Мосэнерго',
issuer: null,
currency: 'RUB',
lot: 1,
nominal: null,
nominalCurrency: null,
maturityDate: null,
sector: null,
country: null,
isActive: true,
);
Future<InstrumentPatch?> _open(
WidgetTester tester,
Future<void> Function() interact,
) async {
InstrumentPatch? result;
var closed = false;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () async {
result = await showDialog<InstrumentPatch>(
context: context,
builder: (_) => InstrumentEditDialog(instrument: _instrument()),
);
closed = true;
},
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await interact();
await tester.pumpAndSettle();
expect(closed, isTrue);
return result;
}
void main() {
testWidgets('only the changed lot goes into the patch', (tester) async {
final patch = await _open(tester, () async {
await tester.enterText(find.widgetWithText(TextFormField, '1'), '1000');
await tester.tap(find.text('Сохранить'));
});
expect(patch, isNotNull);
expect(patch!.lot, 1000);
expect(patch.name, isNull);
expect(patch.board, isNull);
expect(patch.assetClass, isNull);
});
testWidgets('saving without changes closes with nothing to send', (
tester,
) async {
final patch = await _open(tester, () async {
await tester.tap(find.text('Сохранить'));
});
expect(patch, isNull);
});
testWidgets('a lot below 1 is refused and the dialog stays open', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () => showDialog<InstrumentPatch>(
context: context,
builder: (_) => InstrumentEditDialog(instrument: _instrument()),
),
child: const Text('open'),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextFormField, '1'), '0');
await tester.tap(find.text('Сохранить'));
await tester.pumpAndSettle();
expect(find.text('Целое число не меньше 1'), findsOneWidget);
expect(find.text('Сохранить'), findsOneWidget);
});
}
+107
View File
@@ -0,0 +1,107 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fintracker_app/core/cache/cached.dart';
import 'package:fintracker_app/features/accounts/providers.dart';
import 'package:fintracker_app/features/events/manual_event_dialog.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, {
bool disabled = false,
}) => AccountOut(
id: id,
kind: kind,
source_: 'test',
sourceId: 'acc-$id',
broker: null,
name: name,
currency: 'RUB',
role: AccountRole.investment,
includeInNetWorth: true,
mirrorOfAccountId: null,
primaryEventSource: null,
archived: false,
disabled: disabled,
openedAt: null,
balance: null,
balanceAsOf: null,
startBalance: null,
creditLimit: null,
);
Future<void> _open(WidgetTester tester, List<AccountOut> accounts) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
accountsProvider.overrideWith((ref) async => Cached(accounts)),
],
child: MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () => showManualEventDialog(context),
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
}
void main() {
testWidgets('a purchase asks for account, instrument, quantity and price', (
tester,
) async {
await _open(tester, [_account(1, 'Брокер', AccountKind.broker)]);
expect(find.text('Новое событие'), findsOneWidget);
expect(find.text('Инструмент'), findsOneWidget);
expect(find.text('Количество, шт.'), findsOneWidget);
expect(find.text('Цена за штуку'), findsOneWidget);
await tester.tap(find.text('Добавить'));
await tester.pumpAndSettle();
expect(find.text('Выберите счёт'), findsOneWidget);
expect(find.text('Выберите инструмент из списка'), findsOneWidget);
expect(find.text('Обязательное поле'), findsWidgets);
});
testWidgets('only active broker accounts can be picked', (tester) async {
await _open(tester, [
_account(1, 'Брокер', AccountKind.broker),
_account(2, 'Отключённый', AccountKind.broker, disabled: true),
_account(3, 'Карта', AccountKind.zmCard),
]);
await tester.tap(find.text('Брокерский счёт'));
await tester.pumpAndSettle();
expect(find.text('Брокер'), findsOneWidget);
expect(find.text('Отключённый'), findsNothing);
expect(find.text('Карта'), findsNothing);
});
testWidgets(
'a deposit needs only an amount: no instrument, quantity or price',
(tester) async {
await _open(tester, [_account(1, 'Брокер', AccountKind.broker)]);
await tester.tap(find.text('Покупка'));
await tester.pumpAndSettle();
await tester.tap(find.text('Пополнение').last);
await tester.pumpAndSettle();
expect(find.text('Инструмент'), findsNothing);
expect(find.text('Количество, шт.'), findsNothing);
expect(find.text('Цена за штуку'), findsNothing);
expect(find.text('Сумма'), findsOneWidget);
},
);
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fintracker_app/features/home/providers.dart';
import 'package:flutter_test/flutter_test.dart';
MetricsStatusOut _status({required bool refreshing, bool consistent = true}) =>
MetricsStatusOut(
lastRefresh: null,
consistent: consistent,
refreshing: refreshing,
);
void main() {
const fast = Duration(milliseconds: 1);
test('polls until the server stops reporting a rebuild', () async {
final answers = [
_status(refreshing: true),
_status(refreshing: true),
_status(refreshing: false),
];
var calls = 0;
final done = await waitForMetricsRefresh(
() async => answers[calls++],
interval: fast,
);
expect(calls, 3);
expect(done?.refreshing, isFalse);
});
test('returns straight away when nothing is queued', () async {
var calls = 0;
final done = await waitForMetricsRefresh(() async {
calls++;
return _status(refreshing: false);
}, interval: fast);
expect(calls, 1);
expect(done, isNotNull);
});
test('a failed rebuild is handed back so the caller can say so', () async {
final done = await waitForMetricsRefresh(
() async => _status(refreshing: false, consistent: false),
interval: fast,
);
expect(done?.consistent, isFalse);
});
test('gives up with null when the rebuild never finishes', () async {
final done = await waitForMetricsRefresh(
() async => _status(refreshing: true),
interval: fast,
timeout: const Duration(milliseconds: 20),
);
expect(done, isNull);
});
}
+82 -61
View File
@@ -49,26 +49,26 @@ HoldingOut holding({
}
SummaryOut summary() => SummaryOut(
asOf: DateTime(2026, 9, 18),
cashRub: '5700',
computedAt: DateTime.utc(2026, 9, 18, 10),
holdingCount: 2,
incomeRub: '700',
investedNetRub: '20000',
marketValueRub: '11000',
pnlTotalRub: null,
realizedPnlRub: '0',
returns: const [],
scope: 'all',
staleCount: 0,
totalRub: '16700',
unpricedCount: 1,
);
asOf: DateTime(2026, 9, 18),
cashRub: '5700',
computedAt: DateTime.utc(2026, 9, 18, 10),
holdingCount: 2,
incomeRub: '700',
investedNetRub: '20000',
marketValueRub: '11000',
pnlTotalRub: null,
realizedPnlRub: '0',
returns: const [],
scope: 'all',
staleCount: 0,
totalRub: '16700',
unpricedCount: 1,
);
Widget _wrap(Widget child, List<Override> overrides) => ProviderScope(
overrides: overrides,
child: MaterialApp(home: Scaffold(body: child)),
);
overrides: overrides,
child: MaterialApp(home: Scaffold(body: child)),
);
/// The tabs are long scrolling lists and a ListView only builds what fits, so the default
/// 800x600 surface would leave the holdings table unbuilt and every `find` on it empty.
@@ -81,22 +81,30 @@ Future<void> pumpTall(WidgetTester tester, Widget widget) async {
}
void main() {
testWidgets('Позиции show an unpriced holding as «—», never as 0 ₽', (tester) async {
await pumpTall(tester, _wrap(
const HoldingsTab(),
[
testWidgets('Позиции show an unpriced holding as «—», never as 0 ₽', (
tester,
) async {
await pumpTall(
tester,
_wrap(const HoldingsTab(), [
portfolioSummaryProvider.overrideWith((ref) => Cached(summary())),
valueSeriesProvider.overrideWith((ref) => const Cached(<ValueDay>[])),
portfolioReturnsProvider.overrideWith((ref) => const Cached(<ReturnsOut>[])),
holdingsProvider.overrideWith((ref) => Cached([
holding(id: 1, ticker: 'GAZP', valueRub: '11000', weight: '1'),
holding(id: 2, ticker: 'SIBN6P4', priceStatus: 'missing'),
])),
portfolioReturnsProvider.overrideWith(
(ref) => const Cached(<ReturnsOut>[]),
),
holdingsProvider.overrideWith(
(ref) => Cached([
holding(id: 1, ticker: 'GAZP', valueRub: '11000', weight: '1'),
holding(id: 2, ticker: 'SIBN6P4', priceStatus: 'missing'),
]),
),
// HoldingsTab embeds BenchmarksCard, which watches this too — leaving it out would
// fall through to the real apiProvider (see docs/ai/offline-cache.md).
benchmarkRowsProvider.overrideWith((ref) => const Cached(<BenchmarkRow>[])),
],
));
benchmarkRowsProvider.overrideWith(
(ref) => const Cached(<BenchmarkRow>[]),
),
]),
);
expect(find.text('GAZP'), findsOneWidget);
expect(find.text('SIBN6P4'), findsOneWidget);
@@ -107,44 +115,54 @@ void main() {
expect(find.text(MoneyText.format('0', 'RUB')), findsNothing);
});
testWidgets('Позиции show the total profit as «—» while a price is missing', (tester) async {
await pumpTall(tester, _wrap(
const HoldingsTab(),
[
testWidgets('Позиции show the total profit as «—» while a price is missing', (
tester,
) async {
await pumpTall(
tester,
_wrap(const HoldingsTab(), [
portfolioSummaryProvider.overrideWith((ref) => Cached(summary())),
valueSeriesProvider.overrideWith((ref) => const Cached(<ValueDay>[])),
portfolioReturnsProvider.overrideWith((ref) => const Cached(<ReturnsOut>[])),
portfolioReturnsProvider.overrideWith(
(ref) => const Cached(<ReturnsOut>[]),
),
holdingsProvider.overrideWith((ref) => const Cached(<HoldingOut>[])),
benchmarkRowsProvider.overrideWith((ref) => const Cached(<BenchmarkRow>[])),
],
));
benchmarkRowsProvider.overrideWith(
(ref) => const Cached(<BenchmarkRow>[]),
),
]),
);
expect(find.text('Прибыль'), findsOneWidget);
expect(find.text('часть позиций без цены'), findsOneWidget);
});
testWidgets('Аллокация labels the cash and unknown buckets in Russian', (tester) async {
await pumpTall(tester, _wrap(
const AllocationTab(),
[
allocationProvider.overrideWith((ref) => Cached([
AllocationBucket(
bucket: 'share',
dimension: AllocationDimension.assetClass,
holdingCount: 1,
valueRub: '11000',
weight: '0.6587',
),
AllocationBucket(
bucket: 'cash',
dimension: AllocationDimension.assetClass,
holdingCount: 0,
valueRub: '5700',
weight: '0.3413',
),
])),
],
));
testWidgets('Аллокация labels the cash and unknown buckets in Russian', (
tester,
) async {
await pumpTall(
tester,
_wrap(const AllocationTab(), [
allocationProvider.overrideWith(
(ref) => Cached([
AllocationBucket(
bucket: 'share',
dimension: AllocationDimension.assetClass,
holdingCount: 1,
valueRub: '11000',
weight: '0.6587',
),
AllocationBucket(
bucket: 'cash',
dimension: AllocationDimension.assetClass,
holdingCount: 0,
valueRub: '5700',
weight: '0.3413',
),
]),
),
]),
);
expect(find.text('Класс актива'), findsOneWidget);
expect(find.text('Акции'), findsOneWidget);
@@ -168,6 +186,9 @@ void main() {
test('bucketLabel falls back to the source value it does not know', () {
expect(bucketLabel(AllocationDimension.country, 'RU'), 'Россия');
expect(bucketLabel(AllocationDimension.country, 'unknown'), 'Не указано');
expect(bucketLabel(AllocationDimension.sector, 'oil_and_gas'), 'oil_and_gas');
expect(
bucketLabel(AllocationDimension.sector, 'oil_and_gas'),
'oil_and_gas',
);
});
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fintracker_app/core/cache/cached.dart';
import 'package:fintracker_app/features/accounts/providers.dart';
import 'package:fintracker_app/features/portfolios/portfolios_page.dart';
import 'package:fintracker_app/features/portfolios/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) => AccountOut(
id: id,
kind: kind,
source_: 'test',
sourceId: 'acc-$id',
broker: null,
name: name,
currency: 'RUB',
role: AccountRole.investment,
includeInNetWorth: true,
mirrorOfAccountId: null,
primaryEventSource: null,
archived: false,
disabled: false,
openedAt: null,
balance: null,
balanceAsOf: null,
startBalance: null,
creditLimit: null,
);
Widget _app(List<PortfolioOut> portfolios, List<AccountOut> accounts) =>
ProviderScope(
overrides: [
portfolioListProvider.overrideWith((ref) async => portfolios),
accountsProvider.overrideWith((ref) async => Cached(accounts)),
],
child: const MaterialApp(home: PortfoliosPage()),
);
void main() {
testWidgets('an empty list explains what a portfolio is', (tester) async {
await tester.pumpWidget(_app(const [], const []));
await tester.pumpAndSettle();
expect(find.textContaining('Портфелей пока нет'), findsOneWidget);
expect(find.text('Новый портфель'), findsOneWidget);
});
testWidgets('a portfolio shows its accounts by name', (tester) async {
await tester.pumpWidget(
_app(
[
PortfolioOut(
id: 1,
name: 'Основной',
baseCurrency: 'RUB',
accountIds: [10, 11],
),
],
[
_account(10, 'Брокер А', AccountKind.broker),
_account(11, 'Брокер Б', AccountKind.broker),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Основной'), findsOneWidget);
expect(find.text('2 счёта: Брокер А, Брокер Б'), findsOneWidget);
});
testWidgets('the new-portfolio dialog needs a name and lists brokers first', (
tester,
) async {
await tester.pumpWidget(
_app(const [], [
_account(1, 'Карта', AccountKind.zmCard),
_account(2, 'Брокер', AccountKind.broker),
]),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Новый портфель'));
await tester.pumpAndSettle();
final titles = tester
.widgetList<CheckboxListTile>(find.byType(CheckboxListTile))
.toList();
expect((titles.first.title as Text).data, 'Брокер');
await tester.tap(find.text('Сохранить'));
await tester.pumpAndSettle();
expect(find.text('Обязательное поле'), findsOneWidget);
});
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:fintracker_api/fintracker_api.dart';
import 'package:fintracker_app/core/widgets/money_text.dart';
import 'package:fintracker_app/features/home/scope_cards.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
ScopeCardOut _card({
String scope = 'portfolio:1',
String name = 'ИИСы',
String kind = 'portfolio',
String total = '224608.65',
String? pnl = '6643.47',
String? pnlPct = '0.015',
String? day = '-265.68',
String? dayPct = '-0.001',
String? xirr = '0.0298',
String income = '17387.49',
String? incomePct = '0.043',
}) => ScopeCardOut(
scope: scope,
name: name,
kind: kind,
asOf: DateTime(2026, 9, 19),
totalRub: total,
investedRub: '200000',
pnlRub: pnl,
pnlPct: pnlPct,
dayChangeRub: day,
dayChangePct: dayPct,
xirr: xirr,
incomeYearRub: income,
incomeYearPct: incomePct,
);
Widget _app(List<ScopeCardOut> cards, {double width = 1200}) => ProviderScope(
child: MaterialApp(
home: MediaQuery(
data: MediaQueryData(size: Size(width, 900)),
child: Scaffold(
body: SingleChildScrollView(child: ScopeCards(cards: cards)),
),
),
),
);
void main() {
testWidgets('a card shows the name, the value and the four figures', (
tester,
) async {
await tester.pumpWidget(_app([_card()]));
expect(find.text('ИИСЫ'), findsOneWidget);
expect(find.text(MoneyText.format('224608.65', 'RUB')), findsOneWidget);
for (final label in [
'Прибыль',
'За день',
'Доходность',
'Пассивный доход',
]) {
expect(find.text(label), findsOneWidget, reason: label);
}
// the gain carries an explicit plus, the loss its own minus
expect(find.text('+${MoneyText.format('6643.47', 'RUB')}'), findsOneWidget);
expect(find.text(MoneyText.format('-265.68', 'RUB')), findsOneWidget);
expect(find.text('2,98 %'), findsOneWidget);
expect(
find.textContaining(MoneyText.format('17387.49', 'RUB')),
findsOneWidget,
);
});
testWidgets('missing figures read as a dash, not as zero', (tester) async {
await tester.pumpWidget(
_app([
_card(
pnl: null,
pnlPct: null,
day: null,
dayPct: null,
xirr: null,
income: '0',
incomePct: null,
),
]),
);
expect(find.text(''), findsNWidgets(4));
});
testWidgets('cards flow into columns by the available width', (tester) async {
final cards = [
for (var i = 0; i < 6; i++) _card(scope: 'account:$i', name: 'Счёт $i'),
];
addTearDown(tester.view.reset);
Future<int> columns(double width) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
await tester.pumpWidget(_app(cards, width: width));
final xs = {
for (final c in cards)
tester.getTopLeft(find.text('СЧЁТ ${cards.indexOf(c)}')).dx.round(),
};
return xs.length;
}
expect(await columns(1200), 3);
expect(await columns(800), 2);
expect(await columns(400), 1);
});
}
+82
View File
@@ -0,0 +1,82 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:fintracker_app/core/theme/app_theme.dart';
import 'package:fintracker_app/core/widgets/service_mark.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
Future<ByteData> _render(WidgetTester tester, double size) async {
final key = GlobalKey();
tester.view.physicalSize = Size(size, size);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: RepaintBoundary(
key: key,
child: ServiceMark(size: size),
),
),
),
);
final boundary =
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
return (await tester.runAsync(() async {
final image = await boundary.toImage();
return image.toByteData(format: ui.ImageByteFormat.rawRgba);
}))!;
}
Color _at(ByteData data, int size, int x, int y) {
final i = (y * size + x) * 4;
return Color.fromARGB(
data.getUint8(i + 3),
data.getUint8(i),
data.getUint8(i + 1),
data.getUint8(i + 2),
);
}
void main() {
testWidgets(
'a gradient disc with three white bars, and nothing outside the disc',
(tester) async {
const size = 256;
final data = await _render(tester, size.toDouble());
// scale of the 512 design grid
int px(num v) => (v * size / 512).round();
expect(
_at(data, size, 2, 2).a,
0,
reason: 'the corner is outside the disc',
);
// the tallest bar (x 316..380, y 131..381): white in its middle
expect(_at(data, size, px(348), px(256)), const Color(0xFFFFFFFF));
// between two bars the disc shows through, and it is coloured, not white
final gap = _at(data, size, px(210), px(300));
expect(gap.a, 1);
expect(gap.b, greaterThan(gap.r), reason: 'blue-violet, not grey');
// the gradient runs from the accent (top left) toward violet (bottom right)
final start = _at(data, size, px(90), px(90));
final end = _at(data, size, px(430), px(430));
expect(end.r, greaterThan(start.r));
expect(start.b * 255, greaterThan(200));
expect(
(start.g - end.g) * 255,
greaterThan(40),
reason: 'the green channel falls off toward violet',
);
},
);
test('the near end of the gradient is the app accent', () {
expect(AppTheme.seed, const Color(0xFF14AFFF));
expect(ServiceMark.violet, isNot(AppTheme.seed));
});
}