fixes
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user