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:
Dmitry
2026-09-18 13:44:09 +03:00
parent b9c12fa1a1
commit ce0966fca2
99 changed files with 6284 additions and 0 deletions
+26
View File
@@ -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);
});
}
+38
View File
@@ -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});
});
}
+51
View File
@@ -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);
});
}
+10
View File
@@ -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 ₽');
});
}
+31
View File
@@ -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);
});
}
+51
View File
@@ -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);
});
}
+9
View File
@@ -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);
});
}