470 lines
16 KiB
Dart
470 lines
16 KiB
Dart
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),
|
|
);
|
|
|
|
/// Засевает базовый набор данных при создании нового пользователя:
|
|
/// несколько счетов и категорий. Демо-транзакции автоматически НЕ создаются —
|
|
/// их можно добавить вручную через [seedDemoTransactionsForUser] (кнопка в профиле).
|
|
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 {
|
|
await _seedAccounts(userId);
|
|
await _seedCategories(userId);
|
|
}
|
|
|
|
/// Добавляет демо-транзакции для существующего пользователя.
|
|
/// Использует имеющиеся счета/категории; создаёт недостающие.
|
|
Future<void> seedDemoTransactionsForUser(String userId) async {
|
|
final existingAccounts = await accountRepo.watchByUser(userId).first;
|
|
final existingCategories = await categoryRepo.watchByUser(userId).first;
|
|
|
|
Account? card = existingAccounts
|
|
.where((a) => a.type == AccountType.card && !a.archived)
|
|
.firstOrNull;
|
|
Account? cash = existingAccounts
|
|
.where((a) => a.type == AccountType.cash && !a.archived)
|
|
.firstOrNull;
|
|
Account? savings = existingAccounts
|
|
.where((a) => a.type == AccountType.savings && !a.archived)
|
|
.firstOrNull;
|
|
|
|
if (card == null || cash == null || savings == null) {
|
|
final seeded = await _seedAccounts(userId);
|
|
card ??= seeded.card;
|
|
cash ??= seeded.cash;
|
|
savings ??= seeded.savings;
|
|
}
|
|
|
|
Category? findCat(List<Category> cats, String name, CategoryType type) =>
|
|
cats
|
|
.where((c) => c.name == name && c.type == type && !c.archived)
|
|
.firstOrNull;
|
|
|
|
Category? food =
|
|
findCat(existingCategories, 'Продукты', CategoryType.expense);
|
|
Category? rent =
|
|
findCat(existingCategories, 'Жильё', CategoryType.expense);
|
|
Category? transport =
|
|
findCat(existingCategories, 'Транспорт', CategoryType.expense);
|
|
Category? cafe =
|
|
findCat(existingCategories, 'Кафе', CategoryType.expense);
|
|
Category? entertainment =
|
|
findCat(existingCategories, 'Досуг', CategoryType.expense);
|
|
Category? salary =
|
|
findCat(existingCategories, 'Зарплата', CategoryType.income);
|
|
|
|
if (food == null ||
|
|
rent == null ||
|
|
transport == null ||
|
|
cafe == null ||
|
|
entertainment == null ||
|
|
salary == null) {
|
|
final seeded = await _seedCategories(userId);
|
|
food ??= seeded.food;
|
|
rent ??= seeded.rent;
|
|
transport ??= seeded.transport;
|
|
cafe ??= seeded.cafe;
|
|
entertainment ??= seeded.entertainment;
|
|
salary ??= seeded.salary;
|
|
}
|
|
|
|
await _seedDemoTransactions(
|
|
userId,
|
|
_SeededAccounts(card: card, cash: cash, savings: savings),
|
|
_SeededCategories(
|
|
food: food,
|
|
rent: rent,
|
|
transport: transport,
|
|
cafe: cafe,
|
|
entertainment: entertainment,
|
|
salary: salary,
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
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));
|
|
// now.month - 1 == 0 корректно даёт декабрь прошлого года (Dart нормализует)
|
|
DateTime inPrevMonth(int day, [int h = 12, int m = 0]) =>
|
|
DateTime(now.year, now.month - 1, day, h, m);
|
|
|
|
final demo = <_Demo>[
|
|
// ── Май (текущий месяц) ────────────────────────────────────────────────
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.food.id,
|
|
type: TransactionType.expense,
|
|
amount: 234000,
|
|
date: atToday(19, 42),
|
|
merchant: 'Лента',
|
|
extraInfo: 'Чек №481523',
|
|
obligation: SpendingObligation.required),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.cafe.id,
|
|
type: TransactionType.expense,
|
|
amount: 48000,
|
|
date: atToday(9, 15),
|
|
merchant: 'Кофе Хауз',
|
|
extraInfo: 'Двойной эспрессо + круассан',
|
|
obligation: SpendingObligation.optional,
|
|
impulse: SpendingImpulse.impulsive),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.transport.id,
|
|
type: TransactionType.expense,
|
|
amount: 6200,
|
|
date: atToday(8, 50),
|
|
merchant: 'Метро',
|
|
obligation: SpendingObligation.required),
|
|
_Demo(
|
|
accountId: a.cash.id,
|
|
categoryId: c.food.id,
|
|
type: TransactionType.expense,
|
|
amount: 112000,
|
|
date: atYesterday(21, 8),
|
|
merchant: 'Перекрёсток',
|
|
extraInfo: 'Чек №209847',
|
|
obligation: SpendingObligation.required),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.entertainment.id,
|
|
type: TransactionType.expense,
|
|
amount: 65000,
|
|
date: atYesterday(19, 30),
|
|
merchant: 'Кинотеатр',
|
|
extraInfo: '2 билета · зал IMAX',
|
|
obligation: SpendingObligation.unnecessary,
|
|
impulse: SpendingImpulse.impulsive),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
transferToAccountId: a.cash.id,
|
|
type: TransactionType.transfer,
|
|
amount: 200000,
|
|
date: daysAgo(2, 16, 30),
|
|
merchant: 'Снятие наличных'),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.rent.id,
|
|
type: TransactionType.expense,
|
|
amount: 3200000,
|
|
date: daysAgo(3, 12, 0),
|
|
merchant: 'Аренда квартиры',
|
|
extraInfo: 'Май 2026 · ул. Ленина 12',
|
|
obligation: SpendingObligation.required),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.salary.id,
|
|
type: TransactionType.income,
|
|
amount: 9500000,
|
|
date: daysAgo(4, 11, 0),
|
|
merchant: 'Зарплата',
|
|
extraInfo: 'Аванс за апрель'),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.transport.id,
|
|
type: TransactionType.expense,
|
|
amount: 34000,
|
|
date: daysAgo(4, 18, 30),
|
|
merchant: 'Яндекс Такси',
|
|
obligation: SpendingObligation.optional,
|
|
impulse: SpendingImpulse.considered),
|
|
_Demo(
|
|
accountId: a.cash.id,
|
|
categoryId: c.cafe.id,
|
|
type: TransactionType.expense,
|
|
amount: 72000,
|
|
date: daysAgo(5, 14, 0),
|
|
merchant: 'Шоколадница',
|
|
extraInfo: 'Обед с коллегами',
|
|
obligation: SpendingObligation.optional,
|
|
impulse: SpendingImpulse.impulsive),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.food.id,
|
|
type: TransactionType.expense,
|
|
amount: 89000,
|
|
date: daysAgo(5, 9, 20),
|
|
merchant: 'Магнит',
|
|
obligation: SpendingObligation.required),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
transferToAccountId: a.savings.id,
|
|
type: TransactionType.transfer,
|
|
amount: 500000,
|
|
date: daysAgo(7, 11, 0),
|
|
merchant: 'Пополнение копилки'),
|
|
// ── Апрель ────────────────────────────────────────────────────────────
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.entertainment.id,
|
|
type: TransactionType.expense,
|
|
amount: 120000,
|
|
date: inPrevMonth(27, 20, 0),
|
|
merchant: 'Концерт',
|
|
extraInfo: '2 билета',
|
|
obligation: SpendingObligation.unnecessary,
|
|
impulse: SpendingImpulse.impulsive),
|
|
_Demo(
|
|
accountId: a.cash.id,
|
|
categoryId: c.food.id,
|
|
type: TransactionType.expense,
|
|
amount: 88000,
|
|
date: inPrevMonth(25, 11, 10),
|
|
merchant: 'Перекрёсток',
|
|
obligation: SpendingObligation.required),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.transport.id,
|
|
type: TransactionType.expense,
|
|
amount: 38000,
|
|
date: inPrevMonth(22, 19, 0),
|
|
merchant: 'Яндекс Такси',
|
|
obligation: SpendingObligation.optional,
|
|
impulse: SpendingImpulse.considered),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.food.id,
|
|
type: TransactionType.expense,
|
|
amount: 195000,
|
|
date: inPrevMonth(19, 17, 40),
|
|
merchant: 'Лента',
|
|
obligation: SpendingObligation.required),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
transferToAccountId: a.cash.id,
|
|
type: TransactionType.transfer,
|
|
amount: 150000,
|
|
date: inPrevMonth(17, 10, 0),
|
|
merchant: 'Снятие наличных'),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.transport.id,
|
|
type: TransactionType.expense,
|
|
amount: 6200,
|
|
date: inPrevMonth(14, 8, 45),
|
|
merchant: 'Метро',
|
|
obligation: SpendingObligation.required),
|
|
_Demo(
|
|
accountId: a.cash.id,
|
|
categoryId: c.cafe.id,
|
|
type: TransactionType.expense,
|
|
amount: 55000,
|
|
date: inPrevMonth(12, 13, 30),
|
|
merchant: 'Шоколадница',
|
|
obligation: SpendingObligation.optional,
|
|
impulse: SpendingImpulse.impulsive),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
transferToAccountId: a.savings.id,
|
|
type: TransactionType.transfer,
|
|
amount: 500000,
|
|
date: inPrevMonth(11, 12, 0),
|
|
merchant: 'Пополнение копилки'),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.salary.id,
|
|
type: TransactionType.income,
|
|
amount: 9500000,
|
|
date: inPrevMonth(10, 11, 0),
|
|
merchant: 'Зарплата',
|
|
extraInfo: 'Оклад за март'),
|
|
_Demo(
|
|
accountId: a.card.id,
|
|
categoryId: c.rent.id,
|
|
type: TransactionType.expense,
|
|
amount: 3200000,
|
|
date: inPrevMonth(5, 12, 0),
|
|
merchant: 'Аренда квартиры',
|
|
extraInfo: 'Апрель 2026 · ул. Ленина 12',
|
|
obligation: SpendingObligation.required),
|
|
];
|
|
|
|
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,
|
|
merchant: d.merchant,
|
|
extraInfo: d.extraInfo,
|
|
transferToAccountId: d.transferToAccountId,
|
|
obligation: d.obligation,
|
|
impulse: d.impulse,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
this.categoryId,
|
|
required this.type,
|
|
required this.amount,
|
|
required this.date,
|
|
this.merchant,
|
|
this.extraInfo,
|
|
this.transferToAccountId,
|
|
this.obligation,
|
|
this.impulse,
|
|
});
|
|
final String accountId;
|
|
final String? categoryId;
|
|
final TransactionType type;
|
|
final int amount;
|
|
final DateTime date;
|
|
final String? merchant;
|
|
final String? extraInfo;
|
|
final String? transferToAccountId;
|
|
final SpendingObligation? obligation;
|
|
final SpendingImpulse? impulse;
|
|
}
|