feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила
Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar на узком. Токены в flutter_secure_storage, на web access живёт в памяти. Интерцептор подставляет токен и делает ровно один refresh на 401. Деньги приходят строками и форматируются через Decimal: парсить их в double значило бы терять копейки ровно там, где бэкенд их бережёт.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import 'package:fintracker_app/features/shell/app_shell.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
Widget wrap(double width) {
|
||||
return MediaQuery(
|
||||
data: MediaQueryData(size: Size(width, 800)),
|
||||
child: MaterialApp(
|
||||
home: AppShell(location: '/', child: Container()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('shows a NavigationRail on a wide surface', (tester) async {
|
||||
await tester.pumpWidget(wrap(1280));
|
||||
expect(find.byType(NavigationRail), findsOneWidget);
|
||||
expect(find.byType(NavigationBar), findsNothing);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_app/core/api/api_client.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Captures whatever `onRequest` passes on, so the rewritten query can be inspected.
|
||||
class _Capture extends RequestInterceptorHandler {
|
||||
RequestOptions? seen;
|
||||
|
||||
@override
|
||||
void next(RequestOptions options) => seen = options;
|
||||
}
|
||||
|
||||
void main() {
|
||||
RequestOptions run(Map<String, dynamic> query) {
|
||||
final handler = _Capture();
|
||||
DateQueryInterceptor().onRequest(
|
||||
RequestOptions(path: '/networth/series', queryParameters: query),
|
||||
handler,
|
||||
);
|
||||
return handler.seen!;
|
||||
}
|
||||
|
||||
test('a DateTime becomes a plain YYYY-MM-DD date', () {
|
||||
// the value the generated client hands over: DateTime.now(), time and all
|
||||
final out = run({'from': DateTime(2025, 9, 18, 10, 31, 29, 84)});
|
||||
expect(out.queryParameters['from'], '2025-09-18');
|
||||
});
|
||||
|
||||
test('single-digit months and days keep two digits', () {
|
||||
final out = run({'from': DateTime(2026, 1, 5)});
|
||||
expect(out.queryParameters['from'], '2026-01-05');
|
||||
});
|
||||
|
||||
test('non-date parameters are passed through untouched', () {
|
||||
final out = run({'page': 2, 'q': 'кофе', 'include_deleted': false});
|
||||
expect(out.queryParameters, {'page': 2, 'q': 'кофе', 'include_deleted': false});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/core/widgets/money_text.dart';
|
||||
import 'package:fintracker_app/features/home/home_page.dart';
|
||||
import 'package:fintracker_app/features/home/providers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Обзор renders stat tiles from a net worth breakdown', (tester) async {
|
||||
final 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',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
netWorthBreakdownProvider.overrideWith((ref) => breakdown),
|
||||
netWorthSeriesProvider.overrideWith((ref) => const <NetWorthDay>[]),
|
||||
cashflowThisMonthProvider.overrideWith((ref) => null),
|
||||
cashflowLast12Provider.overrideWith((ref) => const <CashFlowMonth>[]),
|
||||
runwayProvider.overrideWith((ref) => RunwayOut(
|
||||
asOf: DateTime(2026, 9, 17),
|
||||
avgBaseline3mRub: '0',
|
||||
liquidReserveRub: '0',
|
||||
runwayMonths: null,
|
||||
)),
|
||||
metricsStatusProvider.overrideWith((ref) => null),
|
||||
dataQualityProvider.overrideWith((ref) => const <DataQualityRow>[]),
|
||||
],
|
||||
child: const MaterialApp(home: HomePage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Капитал сегодня'), findsOneWidget);
|
||||
expect(find.text(MoneyText.format('123456.78', 'RUB')), findsOneWidget);
|
||||
expect(find.text('Ликвидные'), findsOneWidget);
|
||||
expect(find.text(MoneyText.format('3000', 'RUB')), findsOneWidget);
|
||||
expect(find.text('Долги'), findsOneWidget);
|
||||
expect(find.text(MoneyText.format('1000', 'RUB')), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:fintracker_app/core/widgets/money_text.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('formats a decimal-string RUB amount with ru_RU grouping', () {
|
||||
// intl's ru_RU locale data uses U+00A0 (NBSP) as both the group and
|
||||
// currency separator, so the expected string is not plain ASCII spaces.
|
||||
expect(MoneyText.format('1234.5', 'RUB'), '1 234,50 ₽');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/features/rules/providers.dart';
|
||||
import 'package:fintracker_app/features/rules/rules_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('the add-rule dialog requires a non-empty pattern', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
rulesListProvider.overrideWith((ref) => const <RuleOut>[]),
|
||||
rulesStaleProvider.overrideWith((ref) => const <RuleOut>[]),
|
||||
],
|
||||
child: const MaterialApp(home: RulesPage()),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Новое правило'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Сохранить'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Обязательное поле'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:fintracker_app/core/widgets/money_text.dart';
|
||||
import 'package:fintracker_app/features/transactions/transaction_row.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
TransactionOut _usdExpense() => TransactionOut(
|
||||
categoryId: 1,
|
||||
comment: null,
|
||||
date: DateTime(2026, 9, 10),
|
||||
deleted: false,
|
||||
flowType: FlowType.expense,
|
||||
hold: false,
|
||||
id: 1,
|
||||
income: '0',
|
||||
incomeAccountId: null,
|
||||
incomeCurrency: null,
|
||||
incomeRub: null,
|
||||
isOneOff: false,
|
||||
mcc: null,
|
||||
outcome: '100',
|
||||
outcomeAccountId: 7,
|
||||
outcomeCurrency: 'USD',
|
||||
outcomeRub: '9500.00',
|
||||
payee: 'Amazon.com',
|
||||
payeeCanonical: 'Amazon',
|
||||
sourceId: 'src-1',
|
||||
tags: const [],
|
||||
tripId: null,
|
||||
ts: DateTime(2026, 9, 10),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('shows a USD amount with its RUB equivalent as secondary text', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: TransactionRow(
|
||||
transaction: _usdExpense(),
|
||||
categoryName: 'Покупки',
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Amazon'), findsOneWidget);
|
||||
expect(find.text(MoneyText.format('100', 'USD')), findsOneWidget);
|
||||
expect(find.text(MoneyText.format('9500.00', 'RUB')), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:fintracker_app/core/auth/auth_controller.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
test('AuthState.signedIn reflects status', () {
|
||||
expect(const AuthState(AuthStatus.signedIn, accessToken: 't').signedIn, isTrue);
|
||||
expect(const AuthState(AuthStatus.signedOut).signedIn, isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user