This commit is contained in:
2026-05-27 17:01:17 +03:00
parent 9e4f7ae751
commit 0284d491f5
6 changed files with 887 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
# NewBudget — Flutter personal finance app
## Commands
```bash
flutter pub get
dart run build_runner build --delete-conflicting-outputs # after changing @riverpod / @DriftDatabase / @freezed / ARB
flutter gen-l10n # regenerate localizations (also runs with pub get)
flutter analyze
flutter run # Android emulator
flutter test
```
> Run `build_runner` whenever you touch any `.dart` file that has `@riverpod`, `@DriftDatabase`,
> `@freezed`, or `@JsonSerializable` annotations, or after editing `lib/l10n/*.arb`.
## Stack
| Concern | Library |
|---|---|
| State | `flutter_riverpod` + `riverpod_annotation` + `riverpod_generator` (code-gen) |
| Database | `drift` + `drift_flutter` (SQLite, reactive streams) |
| Navigation | `go_router` v17 — `StatefulShellRoute.indexedStack` (4 bottom-tab branches) |
| Entities | `freezed_annotation` (immutable, copyWith, ==) |
| Localization | `flutter_localizations` + ARB → `flutter gen-l10n``lib/l10n/` |
| Charts | `fl_chart` |
| Fonts | `google_fonts` |
**NOT used:** `shared_preferences`, `riverpod_lint`/`custom_lint` (intentionally omitted).
## Architecture (feature-first, layered — do not break)
```
presentation → application → domain ← data
(UI) (Riverpod) (pure Dart) (Drift impl)
```
- **presentation** — Widgets only. Uses `ref.watch(...)`, calls controller methods. No Drift imports.
- **application** — `@riverpod` Notifier/AsyncNotifier controllers. Validation, orchestration, UI state.
Depends only on domain abstractions.
- **domain** — pure-Dart entities + abstract repository interfaces. Zero Flutter/Drift deps.
- **data** — Drift tables, DAOs, mappers (row ↔ entity), repository implementations. Only layer that touches SQL.
No use-case classes — controllers call repositories directly.
## Key directory map
```
lib/
main.dart # runApp(ProviderScope(child: NewBudgetApp()))
l10n/ # generated: app_localizations*.dart
src/
app/
app.dart # MaterialApp.router, theme, locale
l10n/l10n.dart # context.l10n extension
router/app_router.dart # GoRouter + StatefulShellRoute (4 tabs)
router/app_routes.dart # route path constants
theme/app_theme.dart # light/dark ThemeData
theme/app_colors.dart # Palette extension (paper/ink/line/accent/positive/negative)
theme/theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — in-memory for now
core/
database/app_database.dart # @DriftDatabase, schemaVersion=2
database/tables/ # users / app_preferences / settings / accounts / categories / transactions
database/daos/ # *_dao.dart with .watch*() methods
database/converters/enum_converters.dart # TypeConverter + re-exports all enums (UI imports enums from here)
providers/database_provider.dart # @Riverpod(keepAlive) AppDatabase
money/money.dart # amounts stored as int minor units (kopecks/cents)
features/
user/ # domain + data + application ready; presentation empty
settings/ # domain + data + application ready; presentation empty
accounts/ # domain + data + application ready; presentation placeholder
categories/ # domain + data + application ready; presentation empty
transactions/# domain + data + application ready; presentation empty
home/presentation/
screens/home_screen.dart # ConsumerWidget, assembles widgets below
widgets/ # MonthHeader, AccountTabs, MonthKpiCard, CategoryDonutCard,
# TransactionsSection, DayHeader, TxRow, MoneyText, FabAddTransaction
state/selected_category_filter.dart # account/category filter providers
month_summary.dart # client-side aggregates for KPI / donut
_mock_data.dart # TEMPORARY mock providers + icon mappers (to be deleted)
analytics/ # placeholder
profile/ # placeholder (theme switcher only)
shared/
widgets/app_scaffold.dart # StatefulShellRoute wrapper + AppBottomNav
formatters/ # EMPTY — planned intl money/date formatters
```
## Data model
All amounts: `int` minor units. All IDs: `String` UUID v4 (client-generated, cloud-sync ready).
Every domain table has a `userId` FK → `users`.
| Table | Key fields |
|---|---|
| `users` | id, name, createdAt |
| `app_preferences` | key (PK), value — stores `active_user_id` |
| `settings` | userId FK, baseCurrency, themeMode(enum), locale, firstDayOfMonth |
| `accounts` | id, userId, name, type(enum), currency, initialBalance(int), iconCode, colorValue, archived |
| `categories` | id, userId, name, type(enum), iconCode, colorValue, parentId(nullable), archived |
| `transactions` | id, userId, accountId, categoryId(nullable), type(enum), amount(int), date, note(nullable), transferToAccountId(nullable), createdAt |
Enums live alongside their Drift tables; `enum_converters.dart` is the single import point for UI.
## Code-gen gotchas
- Generated files: `*.g.dart` (Riverpod/Drift/JSON), `*.freezed.dart`. Both are excluded from analysis but must be committed.
- After any schema change to `@DriftDatabase` or table files, re-run `build_runner` **and** bump `schemaVersion` in `app_database.dart`.
- Riverpod `@riverpod` providers generate into the same `*.g.dart` — don't split provider + its generated file across separate `part` directives in unexpected ways.
## Localization
ARB files in `lib/l10n/app_en.arb` and `lib/l10n/app_ru.arb`.
Access strings via `context.l10n.someKey` (extension from `src/app/l10n/l10n.dart`).
After editing ARB files run `flutter gen-l10n` (or `flutter pub get`).
## Theme / colors
Use `Theme.of(context).extension<Palette>()!` for brand colors.
Palette tokens: `paper`, `ink`, `line`, `accent`, `positive`, `negative`.
Do not use hard-coded color constants in widgets.
## Active user
Active profile is stored in `app_preferences` table with key `active_user_id`.
Read via `activeUserControllerProvider`. Currently home screen uses a hard-coded mock userId —
transitioning to the real provider is priority #1 (see PLAN.md).
## What's left (priority order)
1. Replace `_mock_data.dart` mock providers with real DAO stream providers in Home widgets
2. Wire `activeUserControllerProvider` userId throughout all feature streams
3. Add/Edit Transaction screen (FAB → `/transactions/new`)
4. Accounts, Analytics, Profile screens
5. Persist theme/locale via `settingsController` (replace in-memory `themeModeController`)
6. Transfer transactions: fix balance aggregation (`case transfer: break;` in `month_summary.dart`)
7. `shared/formatters/` — intl money + date formatters
8. Unit tests (start with repository layer using `AppDatabase.forTesting()`)
## Open decisions (discuss before implementing)
- Transfer model: single record with `transferToAccountId` vs paired income+expense records
- Aggregates: client-side Provider (current) vs SQL `watchTotalsByCategory(period)` in DAO
- `riverpod_lint`/`custom_lint`: re-add or keep omitted
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../../domain/entities/account.dart';
IconData iconForAccount(Account a) {
switch (a.type) {
case AccountType.cash:
return Icons.payments_outlined;
case AccountType.card:
return Icons.credit_card_outlined;
case AccountType.bank:
return Icons.account_balance_outlined;
case AccountType.savings:
return Icons.savings_outlined;
}
}
String shortAccountLabel(Account a) {
switch (a.type) {
case AccountType.cash:
return 'Кэш';
case AccountType.card:
return 'Карта';
case AccountType.bank:
return 'Банк';
case AccountType.savings:
return 'Копилка';
}
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import '../../domain/entities/category.dart';
const int icoCategoryFood = 1;
const int icoCategoryHouse = 2;
const int icoCategoryTransport = 3;
const int icoCategoryCafe = 4;
const int icoCategoryEntertainment = 5;
const int icoCategoryOther = 6;
IconData iconForCategory(Category c) {
switch (c.iconCode) {
case icoCategoryFood:
return Icons.shopping_cart_outlined;
case icoCategoryHouse:
return Icons.home_outlined;
case icoCategoryTransport:
return Icons.directions_car_outlined;
case icoCategoryCafe:
return Icons.restaurant_outlined;
case icoCategoryEntertainment:
return Icons.movie_outlined;
default:
return Icons.more_horiz;
}
}
Color colorForCategory(Category c) => Color(c.colorValue ?? 0xFFB8B5AC);
@@ -0,0 +1,268 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/database/converters/enum_converters.dart';
import '../../accounts/application/account_providers.dart';
import '../../accounts/domain/entities/account.dart';
import '../../accounts/domain/repositories/account_repository.dart';
import '../../categories/application/category_providers.dart';
import '../../categories/domain/entities/category.dart';
import '../../categories/domain/repositories/category_repository.dart';
import '../../categories/presentation/widgets/category_icon.dart';
import '../../transactions/application/transaction_providers.dart';
import '../../transactions/domain/repositories/transaction_repository.dart';
part 'user_seeder.g.dart';
@Riverpod(keepAlive: true)
UserSeeder userSeeder(Ref ref) => UserSeeder(
accountRepo: ref.watch(accountRepositoryProvider),
categoryRepo: ref.watch(categoryRepositoryProvider),
txRepo: ref.watch(transactionRepositoryProvider),
);
/// Засевает базовый набор данных при создании нового пользователя:
/// несколько счетов и категорий + демонстрационные транзакции.
///
/// Демо-транзакции временные — удалить, когда появится UI создания транзакций.
class UserSeeder {
UserSeeder({
required this.accountRepo,
required this.categoryRepo,
required this.txRepo,
});
final AccountRepository accountRepo;
final CategoryRepository categoryRepo;
final TransactionRepository txRepo;
Future<void> seedForNewUser(String userId) async {
final accounts = await _seedAccounts(userId);
final categories = await _seedCategories(userId);
await _seedDemoTransactions(userId, accounts, categories);
}
Future<_SeededAccounts> _seedAccounts(String userId) async {
final card = await accountRepo.create(
userId: userId,
name: 'Карта',
type: AccountType.card,
currency: 'RUB',
initialBalance: 14250000,
colorValue: _argb(0x8AA6A0),
);
final cash = await accountRepo.create(
userId: userId,
name: 'Наличные',
type: AccountType.cash,
currency: 'RUB',
initialBalance: 1282000,
colorValue: _argb(0xC89A86),
);
final savings = await accountRepo.create(
userId: userId,
name: 'Копилка',
type: AccountType.savings,
currency: 'RUB',
initialBalance: 2900000,
colorValue: _argb(0xB3A589),
);
return _SeededAccounts(card: card, cash: cash, savings: savings);
}
Future<_SeededCategories> _seedCategories(String userId) async {
final food = await categoryRepo.create(
userId: userId,
name: 'Продукты',
type: CategoryType.expense,
iconCode: icoCategoryFood,
colorValue: _argb(0x8AA6A0),
);
final rent = await categoryRepo.create(
userId: userId,
name: 'Жильё',
type: CategoryType.expense,
iconCode: icoCategoryHouse,
colorValue: _argb(0xC89A86),
);
final transport = await categoryRepo.create(
userId: userId,
name: 'Транспорт',
type: CategoryType.expense,
iconCode: icoCategoryTransport,
colorValue: _argb(0xB3A589),
);
final cafe = await categoryRepo.create(
userId: userId,
name: 'Кафе',
type: CategoryType.expense,
iconCode: icoCategoryCafe,
colorValue: _argb(0x9FB38A),
);
final entertainment = await categoryRepo.create(
userId: userId,
name: 'Досуг',
type: CategoryType.expense,
iconCode: icoCategoryEntertainment,
colorValue: _argb(0xA99CB9),
);
final salary = await categoryRepo.create(
userId: userId,
name: 'Зарплата',
type: CategoryType.income,
iconCode: icoCategoryOther,
colorValue: _argb(0xB8B5AC),
);
return _SeededCategories(
food: food,
rent: rent,
transport: transport,
cafe: cafe,
entertainment: entertainment,
salary: salary,
);
}
// ── DEMO: удалить, когда появится экран добавления транзакций ──────────────
Future<void> _seedDemoTransactions(
String userId,
_SeededAccounts a,
_SeededCategories c,
) async {
final now = DateTime.now();
DateTime atToday(int h, int m) =>
DateTime(now.year, now.month, now.day, h, m);
DateTime atYesterday(int h, int m) =>
atToday(h, m).subtract(const Duration(days: 1));
DateTime daysAgo(int d, [int h = 12, int m = 0]) =>
DateTime(now.year, now.month, now.day, h, m)
.subtract(Duration(days: d));
final demo = <_Demo>[
_Demo(
accountId: a.card.id,
categoryId: c.food.id,
type: TransactionType.expense,
amount: 234000,
date: atToday(19, 42),
note: 'Лента'),
_Demo(
accountId: a.card.id,
categoryId: c.cafe.id,
type: TransactionType.expense,
amount: 48000,
date: atToday(9, 15),
note: 'Кофе Хауз'),
_Demo(
accountId: a.card.id,
categoryId: c.transport.id,
type: TransactionType.expense,
amount: 6200,
date: atToday(8, 50),
note: 'Метро'),
_Demo(
accountId: a.cash.id,
categoryId: c.food.id,
type: TransactionType.expense,
amount: 112000,
date: atYesterday(21, 8),
note: 'Перекрёсток'),
_Demo(
accountId: a.card.id,
categoryId: c.entertainment.id,
type: TransactionType.expense,
amount: 65000,
date: atYesterday(19, 30),
note: 'Кинотеатр'),
_Demo(
accountId: a.card.id,
categoryId: c.rent.id,
type: TransactionType.expense,
amount: 3200000,
date: daysAgo(3, 12, 0),
note: 'Аренда квартиры'),
_Demo(
accountId: a.card.id,
categoryId: c.salary.id,
type: TransactionType.income,
amount: 9500000,
date: daysAgo(4, 11, 0),
note: 'Зарплата'),
_Demo(
accountId: a.card.id,
categoryId: c.transport.id,
type: TransactionType.expense,
amount: 34000,
date: daysAgo(4, 18, 30),
note: 'Яндекс Такси'),
_Demo(
accountId: a.cash.id,
categoryId: c.cafe.id,
type: TransactionType.expense,
amount: 72000,
date: daysAgo(5, 14, 0),
note: 'Шоколадница'),
_Demo(
accountId: a.card.id,
categoryId: c.food.id,
type: TransactionType.expense,
amount: 89000,
date: daysAgo(5, 9, 20),
note: 'Магнит'),
];
for (final d in demo) {
await txRepo.create(
userId: userId,
accountId: d.accountId,
categoryId: d.categoryId,
type: d.type,
amount: d.amount,
date: d.date,
note: d.note,
);
}
}
}
int _argb(int rgb) => 0xFF000000 | rgb;
class _SeededAccounts {
_SeededAccounts({required this.card, required this.cash, required this.savings});
final Account card;
final Account cash;
final Account savings;
}
class _SeededCategories {
_SeededCategories({
required this.food,
required this.rent,
required this.transport,
required this.cafe,
required this.entertainment,
required this.salary,
});
final Category food;
final Category rent;
final Category transport;
final Category cafe;
final Category entertainment;
final Category salary;
}
class _Demo {
_Demo({
required this.accountId,
required this.categoryId,
required this.type,
required this.amount,
required this.date,
required this.note,
});
final String accountId;
final String categoryId;
final TransactionType type;
final int amount;
final DateTime date;
final String note;
}
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../application/active_user_controller.dart';
import '../../application/users_controller.dart';
class OnboardingScreen extends ConsumerStatefulWidget {
const OnboardingScreen({super.key});
@override
ConsumerState<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
final _nameCtrl = TextEditingController();
bool _submitting = false;
@override
void dispose() {
_nameCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
final name = _nameCtrl.text.trim();
if (name.isEmpty || _submitting) return;
setState(() => _submitting = true);
try {
final user =
await ref.read(usersControllerProvider.notifier).createUser(name);
await ref
.read(activeUserControllerProvider.notifier)
.setActiveUser(user);
// Redirect в роутере подхватит изменение activeUser и переведёт на /home.
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
return Scaffold(
backgroundColor: p.paper,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Spacer(),
Text(
l10n.onboardingTitle,
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: p.ink,
letterSpacing: -0.4,
),
),
const SizedBox(height: 8),
Text(
l10n.onboardingSubtitle,
style: TextStyle(fontSize: 14, color: p.ink2),
),
const SizedBox(height: 24),
TextField(
controller: _nameCtrl,
autofocus: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
style: TextStyle(fontSize: 16, color: p.ink),
decoration: InputDecoration(
labelText: l10n.onboardingNameLabel,
hintText: l10n.onboardingNameHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: p.line),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: p.line),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: p.accent),
),
),
),
const Spacer(),
SizedBox(
width: double.infinity,
height: 52,
child: FilledButton(
onPressed: _submitting ? null : _submit,
style: FilledButton.styleFrom(
backgroundColor: p.ink,
foregroundColor: p.paper,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _submitting
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(p.paper),
),
)
: Text(
l10n.onboardingContinue,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,286 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:new_budget/l10n/app_localizations.dart';
import 'package:new_budget/src/app/theme/app_theme.dart';
import 'package:new_budget/src/features/user/application/active_user_controller.dart';
import 'package:new_budget/src/features/user/application/users_controller.dart';
import 'package:new_budget/src/features/user/domain/entities/user.dart';
import 'package:new_budget/src/features/user/presentation/screens/onboarding_screen.dart';
// ─── Fakes ────────────────────────────────────────────────────────────────────
//
// FakeUsersController и FakeActiveUserController расширяют реальные контроллеры
// и переопределяют только те методы, которые касаются БД. Riverpod создаёт их
// через фабрику в overrideWith, поэтому ref.watch/ref.read внутри build()
// никогда не вызываются.
class FakeUsersController extends UsersController {
/// Имена, с которыми вызывался createUser.
final List<String> createdNames = [];
/// Если задан, createUser будет ждать завершения этого Completer,
/// что позволяет проверить состояние загрузки.
Completer<User>? _blocker;
/// Вызови до нажатия кнопки, чтобы «заморозить» createUser.
void freezeNextCreate(Completer<User> completer) => _blocker = completer;
@override
AsyncValue<void> build() => const AsyncData(null);
@override
Future<User> createUser(String name) async {
createdNames.add(name);
if (_blocker != null) return _blocker!.future;
return User(id: 'test-uid', name: name, createdAt: DateTime(2024, 1, 1));
}
}
class FakeActiveUserController extends ActiveUserController {
/// Пользователи, переданные в setActiveUser.
final List<User> activatedUsers = [];
/// Стартуем без активного пользователя.
@override
Future<User?> build() async => null;
@override
Future<void> setActiveUser(User user) async {
activatedUsers.add(user);
state = AsyncData(user);
}
}
// ─── Helper ───────────────────────────────────────────────────────────────────
Widget _buildOnboarding({
required FakeUsersController fakeUsers,
required FakeActiveUserController fakeActive,
}) {
return ProviderScope(
overrides: [
usersControllerProvider.overrideWith(() => fakeUsers),
activeUserControllerProvider.overrideWith(() => fakeActive),
],
child: MaterialApp(
theme: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const OnboardingScreen(),
),
);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
void main() {
setUpAll(() {
// Запрещаем GoogleFonts ходить в сеть во время тестов.
GoogleFonts.config.allowRuntimeFetching = false;
});
group('OnboardingScreen', () {
late FakeUsersController fakeUsers;
late FakeActiveUserController fakeActive;
setUp(() {
fakeUsers = FakeUsersController();
fakeActive = FakeActiveUserController();
});
// ── Рендеринг ──────────────────────────────────────────────────────────
testWidgets('отображает заголовок, подзаголовок, поле имени и кнопку',
(tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
// Локализация EN: "Welcome" / "Tell us your name to get started."
expect(find.text('Welcome'), findsOneWidget);
expect(find.text('Tell us your name to get started.'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Continue'), findsOneWidget);
});
testWidgets('TextField имеет лейбл и хинт', (tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
expect(find.text('Your name'), findsOneWidget);
});
// ── Валидация пустого имени ─────────────────────────────────────────────
testWidgets('нажатие Continue с пустым полем не вызывает createUser',
(tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.tap(find.text('Continue'));
await tester.pump();
expect(fakeUsers.createdNames, isEmpty);
expect(fakeActive.activatedUsers, isEmpty);
});
testWidgets('нажатие Continue с пробелами не вызывает createUser',
(tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.enterText(find.byType(TextField), ' ');
await tester.tap(find.text('Continue'));
await tester.pump();
expect(fakeUsers.createdNames, isEmpty);
});
// ── Happy path ─────────────────────────────────────────────────────────
testWidgets('валидное имя: createUser вызывается с trim-значением',
(tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.enterText(find.byType(TextField), ' Alice ');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(fakeUsers.createdNames, ['Alice']);
});
testWidgets(
'после createUser вызывается setActiveUser с возвращённым пользователем',
(tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.enterText(find.byType(TextField), 'Bob');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(fakeActive.activatedUsers, hasLength(1));
expect(fakeActive.activatedUsers.first.name, 'Bob');
});
// ── Отправка через клавиатуру ──────────────────────────────────────────
testWidgets('TextInputAction.done также запускает submit', (tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.enterText(find.byType(TextField), 'Carol');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(fakeUsers.createdNames, ['Carol']);
expect(fakeActive.activatedUsers, isNotEmpty);
});
// ── Состояние загрузки ─────────────────────────────────────────────────
testWidgets(
'во время отправки кнопка показывает CircularProgressIndicator',
(tester) async {
final completer = Completer<User>();
fakeUsers.freezeNextCreate(completer);
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.enterText(find.byType(TextField), 'Dave');
await tester.tap(find.text('Continue'));
await tester.pump(); // Обрабатываем тап + setState(_submitting=true)
// Пока awaiting: должен быть спиннер, кнопка «Continue» не видна.
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Continue'), findsNothing);
// Разблокируем async-операцию.
completer.complete(
User(id: 'u-dave', name: 'Dave', createdAt: DateTime(2024, 1, 1)),
);
await tester.pumpAndSettle();
// После завершения: спиннер исчез, кнопка снова видна.
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Continue'), findsOneWidget);
});
testWidgets('во время отправки кнопка задизейблена (onPressed == null)',
(tester) async {
final completer = Completer<User>();
fakeUsers.freezeNextCreate(completer);
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.enterText(find.byType(TextField), 'Eve');
await tester.tap(find.text('Continue'));
await tester.pump();
final button = tester.widget<FilledButton>(find.byType(FilledButton));
expect(button.onPressed, isNull,
reason: 'Кнопка должна быть disabled во время отправки');
completer.complete(
User(id: 'u-eve', name: 'Eve', createdAt: DateTime(2024, 1, 1)),
);
await tester.pumpAndSettle();
final buttonAfter =
tester.widget<FilledButton>(find.byType(FilledButton));
expect(buttonAfter.onPressed, isNotNull,
reason: 'После завершения кнопка снова активна');
});
testWidgets('повторный тап во время отправки не создаёт второго пользователя',
(tester) async {
final completer = Completer<User>();
fakeUsers.freezeNextCreate(completer);
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.enterText(find.byType(TextField), 'Frank');
await tester.tap(find.text('Continue'));
await tester.pump(); // Первый тап, _submitting = true
// Пробуем тапнуть снова (кнопка задизейблена, но try programmatic).
await tester.tap(find.byType(FilledButton), warnIfMissed: false);
await tester.pump();
// createUser должен был быть вызван только один раз.
expect(fakeUsers.createdNames, hasLength(1));
completer.complete(
User(id: 'u-frank', name: 'Frank', createdAt: DateTime(2024, 1, 1)),
);
await tester.pumpAndSettle();
});
});
}