Add filter
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
"PowerShell(flutter *)",
|
||||
"Bash(flutter analyze *)",
|
||||
"Bash(flutter gen-l10n *)",
|
||||
"Bash(dart run *)"
|
||||
"Bash(dart run *)",
|
||||
"Bash(flutter test *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,17 @@
|
||||
},
|
||||
|
||||
"allCategoriesFilter": "All categories · all types",
|
||||
"filterByCategories": "Filter by category",
|
||||
"clearFilter": "Clear",
|
||||
"selectAll": "Select all",
|
||||
"categoriesSelectedCount": "{count, plural, one{{count} category} other{{count} categories}}",
|
||||
"@categoriesSelectedCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"moreCategories": "{count, plural, one{+ {count} more →} other{+ {count} more →}}",
|
||||
"@moreCategories": {
|
||||
|
||||
@@ -182,6 +182,30 @@ abstract class AppLocalizations {
|
||||
/// **'Все категории · все типы'**
|
||||
String get allCategoriesFilter;
|
||||
|
||||
/// No description provided for @filterByCategories.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Фильтр по категориям'**
|
||||
String get filterByCategories;
|
||||
|
||||
/// No description provided for @clearFilter.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Сбросить'**
|
||||
String get clearFilter;
|
||||
|
||||
/// No description provided for @selectAll.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'Выбрать все'**
|
||||
String get selectAll;
|
||||
|
||||
/// No description provided for @categoriesSelectedCount.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
/// **'{count, plural, one{{count} категория} few{{count} категории} many{{count} категорий} other{{count} категорий}}'**
|
||||
String categoriesSelectedCount(int count);
|
||||
|
||||
/// No description provided for @moreCategories.
|
||||
///
|
||||
/// In ru, this message translates to:
|
||||
|
||||
@@ -58,6 +58,26 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get allCategoriesFilter => 'All categories · all types';
|
||||
|
||||
@override
|
||||
String get filterByCategories => 'Filter by category';
|
||||
|
||||
@override
|
||||
String get clearFilter => 'Clear';
|
||||
|
||||
@override
|
||||
String get selectAll => 'Select all';
|
||||
|
||||
@override
|
||||
String categoriesSelectedCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count categories',
|
||||
one: '$count category',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String moreCategories(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
|
||||
@@ -60,6 +60,28 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get allCategoriesFilter => 'Все категории · все типы';
|
||||
|
||||
@override
|
||||
String get filterByCategories => 'Фильтр по категориям';
|
||||
|
||||
@override
|
||||
String get clearFilter => 'Сбросить';
|
||||
|
||||
@override
|
||||
String get selectAll => 'Выбрать все';
|
||||
|
||||
@override
|
||||
String categoriesSelectedCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count категорий',
|
||||
many: '$count категорий',
|
||||
few: '$count категории',
|
||||
one: '$count категория',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String moreCategories(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
|
||||
@@ -28,6 +28,17 @@
|
||||
},
|
||||
|
||||
"allCategoriesFilter": "Все категории · все типы",
|
||||
"filterByCategories": "Фильтр по категориям",
|
||||
"clearFilter": "Сбросить",
|
||||
"selectAll": "Выбрать все",
|
||||
"categoriesSelectedCount": "{count, plural, one{{count} категория} few{{count} категории} many{{count} категорий} other{{count} категорий}}",
|
||||
"@categoriesSelectedCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"moreCategories": "{count, plural, one{+ ещё {count} категория →} few{+ ещё {count} категории →} many{+ ещё {count} категорий →} other{+ ещё {count} категорий →}}",
|
||||
"@moreCategories": {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/database/converters/enum_converters.dart';
|
||||
import '../../accounts/application/accounts_controller.dart';
|
||||
import '../../accounts/domain/entities/account.dart';
|
||||
import '../../categories/domain/entities/category.dart';
|
||||
import '../../transactions/application/transactions_controller.dart';
|
||||
import '../../transactions/domain/entities/transaction.dart';
|
||||
@@ -10,7 +8,7 @@ import 'state/selected_category_filter.dart';
|
||||
|
||||
part 'month_summary.g.dart';
|
||||
|
||||
/// Сводка по месяцу — баланс, доходы, расходы для текущего выбранного счёта.
|
||||
/// Сводка по месяцу — итог за месяц, доходы, расходы для текущего выбранного счёта.
|
||||
class MonthSummary {
|
||||
const MonthSummary({
|
||||
required this.balanceMinor,
|
||||
@@ -20,6 +18,7 @@ class MonthSummary {
|
||||
required this.transactionsCount,
|
||||
});
|
||||
|
||||
/// Чистый итог за месяц: доходы − расходы (без учёта начального баланса счёта).
|
||||
final int balanceMinor;
|
||||
final int incomeMinor;
|
||||
final int expensesMinor;
|
||||
@@ -30,65 +29,122 @@ class MonthSummary {
|
||||
spendByCategory.values.fold(0, (sum, v) => sum + v);
|
||||
}
|
||||
|
||||
/// Транзакции пользователя, отфильтрованные по выбранному счёту и (опционально)
|
||||
/// по выбранной категории. Источник — `transactionsStream`. Во время первичной
|
||||
/// загрузки возвращается пустой список.
|
||||
/// Транзакции пользователя за выбранный месяц, отфильтрованные по выбранному
|
||||
/// счёту и (опционально) по одной или нескольким выбранным категориям.
|
||||
/// При выборе конкретного счёта включает входящие переводы.
|
||||
@riverpod
|
||||
List<Transaction> filteredTransactions(Ref ref, String userId) {
|
||||
final all = ref.watch(transactionsStreamProvider(userId)).value ??
|
||||
final month = ref.watch(selectedMonthProvider);
|
||||
final monthStart = DateTime(month.year, month.month);
|
||||
final monthEnd = DateTime(month.year, month.month + 1)
|
||||
.subtract(const Duration(milliseconds: 1));
|
||||
|
||||
final all = ref
|
||||
.watch(transactionsStreamProvider(
|
||||
userId,
|
||||
from: monthStart,
|
||||
to: monthEnd,
|
||||
))
|
||||
.value ??
|
||||
const <Transaction>[];
|
||||
|
||||
final accountId = ref.watch(selectedAccountProvider);
|
||||
final categoryId = ref.watch(selectedCategoryFilterProvider);
|
||||
final categoryIds = ref.watch(selectedCategoryFilterProvider);
|
||||
|
||||
return all.where((t) {
|
||||
if (accountId.isNotEmpty && t.accountId != accountId) return false;
|
||||
if (categoryId != null && t.categoryId != categoryId) return false;
|
||||
if (accountId.isNotEmpty) {
|
||||
final isFrom = t.accountId == accountId;
|
||||
final isIncoming = t.type == TransactionType.transfer &&
|
||||
t.transferToAccountId == accountId;
|
||||
if (!isFrom && !isIncoming) return false;
|
||||
}
|
||||
if (categoryIds.isNotEmpty && !categoryIds.contains(t.categoryId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Сводка по месяцу для активного пользователя. Балансы считаются без учёта
|
||||
/// фильтра категории — баланс счёта от неё не зависит.
|
||||
/// Сводка по месяцу для активного пользователя.
|
||||
///
|
||||
/// Балансы считаются без учёта фильтра категории — итог счёта от неё не зависит.
|
||||
///
|
||||
/// Логика переводов:
|
||||
/// - «Все счета»: переводы не учитываются в доходе/расходе (внутренние перемещения).
|
||||
/// - Конкретный счёт: перевод С этого счёта → расход; перевод НА этот счёт → доход.
|
||||
@riverpod
|
||||
MonthSummary monthSummary(Ref ref, String userId) {
|
||||
final accounts = ref.watch(accountsStreamProvider(userId)).value ??
|
||||
const <Account>[];
|
||||
final txs = ref.watch(transactionsStreamProvider(userId)).value ??
|
||||
final month = ref.watch(selectedMonthProvider);
|
||||
final monthStart = DateTime(month.year, month.month);
|
||||
final monthEnd = DateTime(month.year, month.month + 1)
|
||||
.subtract(const Duration(milliseconds: 1));
|
||||
|
||||
final txs = ref
|
||||
.watch(transactionsStreamProvider(
|
||||
userId,
|
||||
from: monthStart,
|
||||
to: monthEnd,
|
||||
))
|
||||
.value ??
|
||||
const <Transaction>[];
|
||||
|
||||
final selectedAccount = ref.watch(selectedAccountProvider);
|
||||
|
||||
final scopedTxs = selectedAccount.isEmpty
|
||||
? txs
|
||||
: txs.where((t) => t.accountId == selectedAccount).toList();
|
||||
// При конкретном счёте включаем также входящие переводы
|
||||
final Iterable<Transaction> scopedTxs;
|
||||
if (selectedAccount.isEmpty) {
|
||||
scopedTxs = txs;
|
||||
} else {
|
||||
scopedTxs = txs.where(
|
||||
(t) =>
|
||||
t.accountId == selectedAccount ||
|
||||
(t.type == TransactionType.transfer &&
|
||||
t.transferToAccountId == selectedAccount),
|
||||
);
|
||||
}
|
||||
|
||||
var income = 0;
|
||||
var expense = 0;
|
||||
final spendByCat = <String, int>{};
|
||||
|
||||
for (final t in scopedTxs) {
|
||||
switch (t.type) {
|
||||
case TransactionType.income:
|
||||
income += t.amount;
|
||||
case TransactionType.expense:
|
||||
expense += t.amount;
|
||||
final cid = t.categoryId;
|
||||
if (cid != null) {
|
||||
spendByCat[cid] = (spendByCat[cid] ?? 0) + t.amount;
|
||||
}
|
||||
case TransactionType.transfer:
|
||||
break;
|
||||
if (selectedAccount.isEmpty) {
|
||||
// Все счета — переводы между счетами не влияют на итог
|
||||
switch (t.type) {
|
||||
case TransactionType.income:
|
||||
income += t.amount;
|
||||
case TransactionType.expense:
|
||||
expense += t.amount;
|
||||
final cid = t.categoryId;
|
||||
if (cid != null) {
|
||||
spendByCat[cid] = (spendByCat[cid] ?? 0) + t.amount;
|
||||
}
|
||||
case TransactionType.transfer:
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Конкретный счёт — переводы учитываются
|
||||
switch (t.type) {
|
||||
case TransactionType.income:
|
||||
income += t.amount;
|
||||
case TransactionType.expense:
|
||||
expense += t.amount;
|
||||
final cid = t.categoryId;
|
||||
if (cid != null) {
|
||||
spendByCat[cid] = (spendByCat[cid] ?? 0) + t.amount;
|
||||
}
|
||||
case TransactionType.transfer:
|
||||
if (t.accountId == selectedAccount) {
|
||||
expense += t.amount; // деньги ушли с этого счёта
|
||||
} else {
|
||||
income += t.amount; // деньги пришли на этот счёт
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final selectedAccounts = selectedAccount.isEmpty
|
||||
? accounts
|
||||
: accounts.where((a) => a.id == selectedAccount);
|
||||
final baseBalance = selectedAccounts.fold<int>(
|
||||
0,
|
||||
(s, Account a) => s + a.initialBalance,
|
||||
);
|
||||
|
||||
return MonthSummary(
|
||||
balanceMinor: baseBalance + income - expense,
|
||||
balanceMinor: income - expense, // итог за месяц (не накопленный баланс)
|
||||
incomeMinor: income,
|
||||
expensesMinor: expense,
|
||||
spendByCategory: spendByCat,
|
||||
|
||||
@@ -5,14 +5,29 @@ part 'selected_category_filter.g.dart';
|
||||
/// Sentinel id для виртуального счёта «Все счета» — это агрегат, а не запись.
|
||||
const String kAllAccountsId = '';
|
||||
|
||||
/// id выбранной категории для фильтра списка транзакций.
|
||||
/// `null` — фильтр снят, отображаются все категории.
|
||||
/// Множество id выбранных категорий для фильтра транзакций.
|
||||
/// Пустое множество — фильтр снят, отображаются все категории.
|
||||
@riverpod
|
||||
class SelectedCategoryFilter extends _$SelectedCategoryFilter {
|
||||
@override
|
||||
String? build() => null;
|
||||
Set<String> build() => const {};
|
||||
|
||||
void select(String? id) => state = id;
|
||||
/// Добавить/убрать категорию из выбранных.
|
||||
void toggle(String id) {
|
||||
final next = Set<String>.from(state);
|
||||
if (next.contains(id)) {
|
||||
next.remove(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
state = next;
|
||||
}
|
||||
|
||||
/// Снять фильтр целиком.
|
||||
void clear() => state = const {};
|
||||
|
||||
/// Заменить всё множество разом (используется в «Выбрать все»).
|
||||
void setAll(Set<String> ids) => state = Set.unmodifiable(ids);
|
||||
}
|
||||
|
||||
/// id выбранного счёта в табах. `kAllAccountsId` ('') — «Все счета».
|
||||
@@ -23,3 +38,26 @@ class SelectedAccount extends _$SelectedAccount {
|
||||
|
||||
void select(String id) => state = id;
|
||||
}
|
||||
|
||||
/// Выбранный месяц для фильтрации транзакций и расчёта итогов.
|
||||
/// Хранится как `DateTime(year, month)` (день всегда 1, время 00:00).
|
||||
@riverpod
|
||||
class SelectedMonth extends _$SelectedMonth {
|
||||
@override
|
||||
DateTime build() {
|
||||
final now = DateTime.now();
|
||||
return DateTime(now.year, now.month);
|
||||
}
|
||||
|
||||
void previous() {
|
||||
final m = state.month == 1 ? 12 : state.month - 1;
|
||||
final y = state.month == 1 ? state.year - 1 : state.year;
|
||||
state = DateTime(y, m);
|
||||
}
|
||||
|
||||
void next() {
|
||||
final m = state.month == 12 ? 1 : state.month + 1;
|
||||
final y = state.month == 12 ? state.year + 1 : state.year;
|
||||
state = DateTime(y, m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class CategoryDonut extends StatelessWidget {
|
||||
required this.slices,
|
||||
required this.size,
|
||||
required this.thickness,
|
||||
this.activeId,
|
||||
this.activeIds = const {},
|
||||
this.onSegmentTap,
|
||||
this.centerChild,
|
||||
});
|
||||
@@ -27,8 +27,12 @@ class CategoryDonut extends StatelessWidget {
|
||||
final List<DonutSlice> slices;
|
||||
final double size;
|
||||
final double thickness;
|
||||
final String? activeId;
|
||||
final ValueChanged<String?>? onSegmentTap;
|
||||
|
||||
/// Множество активных (выбранных) id. Пустое = ни один не выбран (все ярко).
|
||||
final Set<String> activeIds;
|
||||
|
||||
/// Колбэк с id нажатого сегмента.
|
||||
final ValueChanged<String>? onSegmentTap;
|
||||
final Widget? centerChild;
|
||||
|
||||
@override
|
||||
@@ -51,10 +55,11 @@ class CategoryDonut extends StatelessWidget {
|
||||
for (final s in slices)
|
||||
PieChartSectionData(
|
||||
value: s.value,
|
||||
color: activeId == null || activeId == s.id
|
||||
// Тускло, если есть активные, но этот — не в их числе
|
||||
color: activeIds.isEmpty || activeIds.contains(s.id)
|
||||
? s.color
|
||||
: s.color.withValues(alpha: 0.35),
|
||||
radius: activeId == s.id ? activeRadius : radius,
|
||||
: s.color.withValues(alpha: 0.30),
|
||||
radius: activeIds.contains(s.id) ? activeRadius : radius,
|
||||
showTitle: false,
|
||||
),
|
||||
],
|
||||
@@ -63,11 +68,10 @@ class CategoryDonut extends StatelessWidget {
|
||||
touchCallback: (event, response) {
|
||||
if (event is FlTapUpEvent) {
|
||||
final i = response?.touchedSection?.touchedSectionIndex;
|
||||
if (i == null || i < 0 || i >= slices.length) {
|
||||
onSegmentTap?.call(null);
|
||||
} else {
|
||||
if (i != null && i >= 0 && i < slices.length) {
|
||||
onSegmentTap?.call(slices[i].id);
|
||||
}
|
||||
// Тап вне сегмента игнорируем — сброс только через пилюлю
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -24,7 +24,7 @@ class CategoryDonutCard extends ConsumerWidget {
|
||||
final categories =
|
||||
ref.watch(categoriesStreamProvider(userId)).value ??
|
||||
const <Category>[];
|
||||
final selectedCat = ref.watch(selectedCategoryFilterProvider);
|
||||
final selectedCats = ref.watch(selectedCategoryFilterProvider);
|
||||
final sorted = categoriesBySpend(summary.spendByCategory, categories);
|
||||
|
||||
final slices = [
|
||||
@@ -50,11 +50,11 @@ class CategoryDonutCard extends ConsumerWidget {
|
||||
slices: slices,
|
||||
size: 130,
|
||||
thickness: 22,
|
||||
activeId: selectedCat,
|
||||
activeIds: selectedCats,
|
||||
onSegmentTap: (id) {
|
||||
ref
|
||||
.read(selectedCategoryFilterProvider.notifier)
|
||||
.select(id == selectedCat ? null : id);
|
||||
.toggle(id);
|
||||
},
|
||||
centerChild: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../app/l10n/l10n.dart';
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
import '../state/selected_category_filter.dart';
|
||||
|
||||
class MonthHeader extends StatelessWidget {
|
||||
class MonthHeader extends ConsumerWidget {
|
||||
const MonthHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final month = ref.watch(selectedMonthProvider);
|
||||
|
||||
final now = DateTime.now();
|
||||
final monthName = DateFormat.MMMM(locale).format(now);
|
||||
final isCurrentMonth =
|
||||
month.year == now.year && month.month == now.month;
|
||||
|
||||
final monthName = DateFormat.MMMM(locale).format(month);
|
||||
final monthCap = '${monthName[0].toUpperCase()}${monthName.substring(1)}';
|
||||
final title = '$monthCap ${now.year}';
|
||||
final title = '$monthCap ${month.year}';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||
@@ -35,14 +42,35 @@ class MonthHeader extends StatelessWidget {
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: p.ink,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_NavButton(
|
||||
icon: Icons.chevron_left,
|
||||
color: p.ink2,
|
||||
onTap: () =>
|
||||
ref.read(selectedMonthProvider.notifier).previous(),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: p.ink,
|
||||
),
|
||||
),
|
||||
if (!isCurrentMonth) ...[
|
||||
const SizedBox(width: 2),
|
||||
_NavButton(
|
||||
icon: Icons.chevron_right,
|
||||
color: p.ink2,
|
||||
onTap: () =>
|
||||
ref.read(selectedMonthProvider.notifier).next(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -65,3 +93,27 @@ class MonthHeader extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavButton extends StatelessWidget {
|
||||
const _NavButton({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Icon(icon, size: 20, color: color),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import '../../../categories/presentation/widgets/category_icon.dart';
|
||||
import '../month_summary.dart';
|
||||
import '../state/selected_category_filter.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Header секции транзакций
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TransactionsSectionHeader extends ConsumerWidget {
|
||||
const TransactionsSectionHeader({super.key, required this.userId});
|
||||
|
||||
@@ -42,6 +46,10 @@ class TransactionsSectionHeader extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Пилюля фильтра — открывает bottom sheet с мульти-выбором
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class CategoryFilterPill extends ConsumerWidget {
|
||||
const CategoryFilterPill({super.key, required this.userId});
|
||||
|
||||
@@ -54,63 +62,51 @@ class CategoryFilterPill extends ConsumerWidget {
|
||||
final categories =
|
||||
ref.watch(categoriesStreamProvider(userId)).value ??
|
||||
const <Category>[];
|
||||
final selectedId = ref.watch(selectedCategoryFilterProvider);
|
||||
final activeCat = selectedId == null || categories.isEmpty
|
||||
? null
|
||||
: categories.firstWhere(
|
||||
(c) => c.id == selectedId,
|
||||
orElse: () => categories.first,
|
||||
);
|
||||
final selectedIds = ref.watch(selectedCategoryFilterProvider);
|
||||
final count = ref.watch(filteredTransactionsProvider(userId)).length;
|
||||
|
||||
// Строим список выбранных категорий в том же порядке, что в categories
|
||||
final selected = categories.where((c) => selectedIds.contains(c.id)).toList();
|
||||
final isActive = selectedIds.isNotEmpty;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () =>
|
||||
ref.read(selectedCategoryFilterProvider.notifier).select(null),
|
||||
onTap: () => _showCategoryFilterSheet(context, ref, userId, categories),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: p.line),
|
||||
border: Border.all(
|
||||
color: isActive ? p.accent : p.line,
|
||||
width: isActive ? 1.5 : 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: isActive ? p.accentSoft : null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.tune, size: 18, color: p.ink2),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: activeCat == null
|
||||
? Text(
|
||||
l10n.allCategoriesFilter,
|
||||
style: TextStyle(fontSize: 13, color: p.ink),
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colorForCategory(activeCat),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
activeCat.name,
|
||||
style: TextStyle(fontSize: 13, color: p.ink),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Icon(
|
||||
Icons.tune,
|
||||
size: 18,
|
||||
color: isActive ? p.accent : p.ink2,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _PillLabel(
|
||||
selected: selected,
|
||||
allLabel: l10n.allCategoriesFilter,
|
||||
multiLabel: (n) => l10n.categoriesSelectedCount(n),
|
||||
activeColor: p.accent,
|
||||
inactiveColor: p.ink,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Счётчик транзакций
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: p.accentSoft,
|
||||
color: isActive ? p.accent.withValues(alpha: 0.18) : p.accentSoft,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
child: Text(
|
||||
@@ -118,12 +114,24 @@ class CategoryFilterPill extends ConsumerWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: p.accent,
|
||||
color: isActive ? p.accent : p.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(Icons.keyboard_arrow_down, size: 18, color: p.ink2),
|
||||
const SizedBox(width: 4),
|
||||
// Кнопка очистки или стрелка
|
||||
if (isActive)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
ref.read(selectedCategoryFilterProvider.notifier).clear(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Icon(Icons.close, size: 16, color: p.accent),
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Icons.keyboard_arrow_down, size: 18, color: p.ink2),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -131,3 +139,335 @@ class CategoryFilterPill extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Текст внутри пилюли
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _PillLabel extends StatelessWidget {
|
||||
const _PillLabel({
|
||||
required this.selected,
|
||||
required this.allLabel,
|
||||
required this.multiLabel,
|
||||
required this.activeColor,
|
||||
required this.inactiveColor,
|
||||
});
|
||||
|
||||
final List<Category> selected;
|
||||
final String allLabel;
|
||||
final String Function(int n) multiLabel;
|
||||
final Color activeColor;
|
||||
final Color inactiveColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (selected.isEmpty) {
|
||||
return Text(
|
||||
allLabel,
|
||||
style: TextStyle(fontSize: 13, color: inactiveColor),
|
||||
);
|
||||
}
|
||||
if (selected.length == 1) {
|
||||
final cat = selected.first;
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colorForCategory(cat),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
cat.name,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: activeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
// Несколько категорий — показываем цветные точки + текст
|
||||
return Row(
|
||||
children: [
|
||||
// До 3 точек
|
||||
for (final cat in selected.take(3))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 3),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colorForCategory(cat),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
multiLabel(selected.length),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: activeColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bottom sheet с мульти-выбором категорий
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
void _showCategoryFilterSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String userId,
|
||||
List<Category> categories,
|
||||
) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => UncontrolledProviderScope(
|
||||
container: ProviderScope.containerOf(context),
|
||||
child: _CategoryFilterSheet(userId: userId, categories: categories),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CategoryFilterSheet extends ConsumerWidget {
|
||||
const _CategoryFilterSheet({
|
||||
required this.userId,
|
||||
required this.categories,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final List<Category> categories;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final l10n = context.l10n;
|
||||
final selectedIds = ref.watch(selectedCategoryFilterProvider);
|
||||
final allIds = categories.map((c) => c.id).toSet();
|
||||
final allSelected = allIds.isNotEmpty && selectedIds.containsAll(allIds);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.9,
|
||||
expand: false,
|
||||
builder: (_, scrollController) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: p.paper,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Ручка
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: p.line,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Заголовок + кнопки
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.filterByCategories,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: p.ink,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Выбрать все / Снять все
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (allSelected) {
|
||||
ref
|
||||
.read(selectedCategoryFilterProvider.notifier)
|
||||
.clear();
|
||||
} else {
|
||||
ref
|
||||
.read(selectedCategoryFilterProvider.notifier)
|
||||
.setAll(allIds);
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
allSelected ? l10n.clearFilter : l10n.selectAll,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: p.accent,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Divider(height: 1, color: p.line),
|
||||
// Список категорий
|
||||
Expanded(
|
||||
child: categories.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
l10n.allCategoriesFilter,
|
||||
style: TextStyle(color: p.ink2),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
controller: scrollController,
|
||||
itemCount: categories.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
Divider(height: 1, indent: 56, color: p.line),
|
||||
itemBuilder: (context, i) {
|
||||
final cat = categories[i];
|
||||
final isSelected = selectedIds.contains(cat.id);
|
||||
return _CategoryRow(
|
||||
category: cat,
|
||||
isSelected: isSelected,
|
||||
onTap: () => ref
|
||||
.read(selectedCategoryFilterProvider.notifier)
|
||||
.toggle(cat.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Кнопка «Готово»
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: p.accent,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: Text(
|
||||
selectedIds.isEmpty
|
||||
? l10n.allCategoriesFilter
|
||||
: l10n.categoriesSelectedCount(selectedIds.length),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Строка категории в bottom sheet
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class _CategoryRow extends StatelessWidget {
|
||||
const _CategoryRow({
|
||||
required this.category,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Category category;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final catColor = colorForCategory(category);
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Иконка категории с цветом
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: catColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
iconForCategory(category),
|
||||
size: 18,
|
||||
color: catColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Название
|
||||
Expanded(
|
||||
child: Text(
|
||||
category.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: p.ink,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Чекбокс
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? catColor : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: isSelected ? catColor : p.line,
|
||||
width: 1.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../domain/entities/user.dart';
|
||||
import 'user_providers.dart';
|
||||
@@ -11,33 +13,66 @@ Stream<List<User>> usersStream(Ref ref) =>
|
||||
ref.watch(userRepositoryProvider).watchAll();
|
||||
|
||||
/// Контроллер операций над профилями.
|
||||
@riverpod
|
||||
///
|
||||
/// keepAlive: операции вроде [createUser] делают несколько await'ов и используют
|
||||
/// `ref.read` между ними. Без keepAlive auto-dispose уничтожает notifier после
|
||||
/// первого await (никто его не watch'ит из UI), и следующий ref.read падает с
|
||||
/// "Ref ... has been disposed".
|
||||
@Riverpod(keepAlive: true)
|
||||
class UsersController extends _$UsersController {
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncData(null);
|
||||
|
||||
Future<User> createUser(String name) async {
|
||||
state = const AsyncLoading();
|
||||
final result = await AsyncValue.guard(() async {
|
||||
try {
|
||||
final user = await ref.read(userRepositoryProvider).create(name);
|
||||
await ref.read(userSeederProvider).seedForNewUser(user.id);
|
||||
state = const AsyncData(null);
|
||||
return user;
|
||||
});
|
||||
state = result.hasError ? AsyncError(result.error!, StackTrace.current) : const AsyncData(null);
|
||||
return result.value!;
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'createUser failed (name="$name")',
|
||||
name: 'UsersController',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> renameUser(String id, String newName) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() => ref.read(userRepositoryProvider).rename(id, newName),
|
||||
).then((_) => const AsyncData(null));
|
||||
try {
|
||||
await ref.read(userRepositoryProvider).rename(id, newName);
|
||||
state = const AsyncData(null);
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'renameUser failed (id=$id, newName="$newName")',
|
||||
name: 'UsersController',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteUser(String id) async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(
|
||||
() => ref.read(userRepositoryProvider).delete(id),
|
||||
).then((_) => const AsyncData(null));
|
||||
try {
|
||||
await ref.read(userRepositoryProvider).delete(id);
|
||||
state = const AsyncData(null);
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'deleteUser failed (id=$id)',
|
||||
name: 'UsersController',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
state = AsyncError(e, st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
|
||||
.read(activeUserControllerProvider.notifier)
|
||||
.setActiveUser(user);
|
||||
// Redirect в роутере подхватит изменение activeUser и переведёт на /home.
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:new_budget/src/features/accounts/domain/repositories/account_repository.dart';
|
||||
import 'package:new_budget/src/features/categories/domain/repositories/category_repository.dart';
|
||||
import 'package:new_budget/src/features/transactions/domain/repositories/transaction_repository.dart';
|
||||
import 'package:new_budget/src/features/user/application/user_providers.dart';
|
||||
import 'package:new_budget/src/features/user/application/user_seeder.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/domain/repositories/user_repository.dart';
|
||||
|
||||
// ─── Fakes ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Фейкаем границу контроллера с внешним миром (репозиторий + сидер), но НЕ сам
|
||||
// контроллер. Тогда тесты прогоняют реальный код UsersController.createUser
|
||||
// и ловят такие баги как auto-dispose между awaits.
|
||||
|
||||
class FakeUserRepository implements UserRepository {
|
||||
final List<User> _users = [];
|
||||
final List<String> createdNames = [];
|
||||
final List<String> deletedIds = [];
|
||||
final List<(String, String)> renames = [];
|
||||
|
||||
Object? createError;
|
||||
Object? renameError;
|
||||
Object? deleteError;
|
||||
|
||||
/// Контролируемая задержка create — позволяет проверить, что между
|
||||
/// state=AsyncLoading и финальным state notifier остаётся жив.
|
||||
Completer<void>? createGate;
|
||||
|
||||
@override
|
||||
Stream<List<User>> watchAll() => Stream.value(List.unmodifiable(_users));
|
||||
|
||||
@override
|
||||
Future<User?> findById(String id) async =>
|
||||
_users.cast<User?>().firstWhere((u) => u!.id == id, orElse: () => null);
|
||||
|
||||
@override
|
||||
Future<User> create(String name) async {
|
||||
createdNames.add(name);
|
||||
if (createGate != null) await createGate!.future;
|
||||
if (createError != null) throw createError!;
|
||||
final user = User(
|
||||
id: 'uid-${_users.length}',
|
||||
name: name,
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
);
|
||||
_users.add(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<User> rename(String id, String newName) async {
|
||||
renames.add((id, newName));
|
||||
if (renameError != null) throw renameError!;
|
||||
final idx = _users.indexWhere((u) => u.id == id);
|
||||
final updated = User(id: id, name: newName, createdAt: _users[idx].createdAt);
|
||||
_users[idx] = updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(String id) async {
|
||||
deletedIds.add(id);
|
||||
if (deleteError != null) throw deleteError!;
|
||||
_users.removeWhere((u) => u.id == id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Сидер фейкаем целиком — нам важно только, что его позвали и с каким id.
|
||||
class FakeUserSeeder extends UserSeeder {
|
||||
FakeUserSeeder()
|
||||
: super(
|
||||
accountRepo: _UnusedAccountRepo(),
|
||||
categoryRepo: _UnusedCategoryRepo(),
|
||||
txRepo: _UnusedTransactionRepo(),
|
||||
);
|
||||
|
||||
final List<String> seededFor = [];
|
||||
Object? seedError;
|
||||
|
||||
@override
|
||||
Future<void> seedForNewUser(String userId) async {
|
||||
if (seedError != null) throw seedError!;
|
||||
seededFor.add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Заглушки для конструктора UserSeeder — методы вызываться не должны.
|
||||
class _UnusedAccountRepo implements AccountRepository {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation i) =>
|
||||
throw StateError('AccountRepository should not be called in this test');
|
||||
}
|
||||
|
||||
class _UnusedCategoryRepo implements CategoryRepository {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation i) =>
|
||||
throw StateError('CategoryRepository should not be called in this test');
|
||||
}
|
||||
|
||||
class _UnusedTransactionRepo implements TransactionRepository {
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation i) =>
|
||||
throw StateError('TransactionRepository should not be called in this test');
|
||||
}
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
ProviderContainer _makeContainer({
|
||||
required FakeUserRepository repo,
|
||||
required FakeUserSeeder seeder,
|
||||
}) {
|
||||
return ProviderContainer(
|
||||
overrides: [
|
||||
userRepositoryProvider.overrideWithValue(repo),
|
||||
userSeederProvider.overrideWithValue(seeder),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
group('UsersController.createUser', () {
|
||||
late FakeUserRepository repo;
|
||||
late FakeUserSeeder seeder;
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() {
|
||||
repo = FakeUserRepository();
|
||||
seeder = FakeUserSeeder();
|
||||
container = _makeContainer(repo: repo, seeder: seeder);
|
||||
});
|
||||
|
||||
tearDown(() => container.dispose());
|
||||
|
||||
test('создаёт пользователя через репозиторий и сидит начальные данные',
|
||||
() async {
|
||||
final user = await container
|
||||
.read(usersControllerProvider.notifier)
|
||||
.createUser('Alice');
|
||||
|
||||
expect(user.name, 'Alice');
|
||||
expect(repo.createdNames, ['Alice']);
|
||||
expect(seeder.seededFor, [user.id]);
|
||||
});
|
||||
|
||||
test('после успеха состояние = AsyncData(null)', () async {
|
||||
await container
|
||||
.read(usersControllerProvider.notifier)
|
||||
.createUser('Alice');
|
||||
|
||||
final state = container.read(usersControllerProvider);
|
||||
expect(state, isA<AsyncData<void>>());
|
||||
expect(state.hasError, isFalse);
|
||||
});
|
||||
|
||||
// Регрессия: UsersController должен переживать await-границы.
|
||||
// Изначально был @riverpod (auto-dispose) — после первого await
|
||||
// notifier удалялся, и второй ref.read падал с "Ref … has been disposed".
|
||||
test('переживает несколько await-границ при вызове через .read(.notifier)',
|
||||
() async {
|
||||
// Никто не watch'ит провайдер — типичная ситуация при вызове из
|
||||
// обработчика onPressed экрана.
|
||||
final gate = Completer<void>();
|
||||
repo.createGate = gate;
|
||||
|
||||
final notifier = container.read(usersControllerProvider.notifier);
|
||||
final future = notifier.createUser('Alice');
|
||||
|
||||
// Уступаем event loop, чтобы auto-dispose (если бы он был) сработал.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
gate.complete();
|
||||
|
||||
final user = await future;
|
||||
expect(user.name, 'Alice');
|
||||
expect(seeder.seededFor, [user.id], reason: 'сидер должен быть вызван');
|
||||
});
|
||||
|
||||
test('во время выполнения состояние = AsyncLoading', () async {
|
||||
final gate = Completer<void>();
|
||||
repo.createGate = gate;
|
||||
|
||||
final future = container
|
||||
.read(usersControllerProvider.notifier)
|
||||
.createUser('Alice');
|
||||
|
||||
// Даём контроллеру дойти до первого await.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(container.read(usersControllerProvider), isA<AsyncLoading<void>>());
|
||||
|
||||
gate.complete();
|
||||
await future;
|
||||
});
|
||||
|
||||
test('ошибка в репозитории пробрасывается и пишется в state', () async {
|
||||
final boom = Exception('db is on fire');
|
||||
repo.createError = boom;
|
||||
|
||||
await expectLater(
|
||||
container.read(usersControllerProvider.notifier).createUser('Alice'),
|
||||
throwsA(same(boom)),
|
||||
);
|
||||
|
||||
final state = container.read(usersControllerProvider);
|
||||
expect(state, isA<AsyncError<void>>());
|
||||
expect(state.error, same(boom));
|
||||
expect(state.stackTrace, isNotNull);
|
||||
expect(seeder.seededFor, isEmpty, reason: 'сидер не должен вызываться');
|
||||
});
|
||||
|
||||
test('ошибка в сидере пробрасывается и пишется в state', () async {
|
||||
final boom = Exception('seed failed');
|
||||
seeder.seedError = boom;
|
||||
|
||||
await expectLater(
|
||||
container.read(usersControllerProvider.notifier).createUser('Alice'),
|
||||
throwsA(same(boom)),
|
||||
);
|
||||
|
||||
final state = container.read(usersControllerProvider);
|
||||
expect(state, isA<AsyncError<void>>());
|
||||
expect(state.error, same(boom));
|
||||
expect(repo.createdNames, ['Alice'],
|
||||
reason: 'создание уже произошло до падения сидера');
|
||||
});
|
||||
|
||||
// Если после неудачи пользователь жмёт "Continue" второй раз — должно
|
||||
// получиться. Это проверка, что notifier (и его ref) живы между вызовами.
|
||||
test('после ошибки можно вызвать createUser ещё раз и преуспеть', () async {
|
||||
repo.createError = Exception('first attempt fails');
|
||||
final notifier = container.read(usersControllerProvider.notifier);
|
||||
|
||||
await expectLater(notifier.createUser('Alice'), throwsA(isA<Exception>()));
|
||||
|
||||
repo.createError = null;
|
||||
final user = await notifier.createUser('Alice');
|
||||
expect(user.name, 'Alice');
|
||||
expect(repo.createdNames, ['Alice', 'Alice']);
|
||||
});
|
||||
});
|
||||
|
||||
group('UsersController.renameUser', () {
|
||||
late FakeUserRepository repo;
|
||||
late FakeUserSeeder seeder;
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() async {
|
||||
repo = FakeUserRepository();
|
||||
seeder = FakeUserSeeder();
|
||||
container = _makeContainer(repo: repo, seeder: seeder);
|
||||
// Создаём пользователя для последующего rename.
|
||||
await container
|
||||
.read(usersControllerProvider.notifier)
|
||||
.createUser('Alice');
|
||||
});
|
||||
|
||||
tearDown(() => container.dispose());
|
||||
|
||||
test('переименовывает пользователя через репозиторий', () async {
|
||||
await container
|
||||
.read(usersControllerProvider.notifier)
|
||||
.renameUser('uid-0', 'Alice II');
|
||||
|
||||
expect(repo.renames, [('uid-0', 'Alice II')]);
|
||||
expect(container.read(usersControllerProvider), isA<AsyncData<void>>());
|
||||
});
|
||||
|
||||
test('ошибка пробрасывается и пишется в state', () async {
|
||||
final boom = Exception('rename failed');
|
||||
repo.renameError = boom;
|
||||
|
||||
await expectLater(
|
||||
container
|
||||
.read(usersControllerProvider.notifier)
|
||||
.renameUser('uid-0', 'X'),
|
||||
throwsA(same(boom)),
|
||||
);
|
||||
|
||||
final state = container.read(usersControllerProvider);
|
||||
expect(state, isA<AsyncError<void>>());
|
||||
expect(state.error, same(boom));
|
||||
});
|
||||
});
|
||||
|
||||
group('UsersController.deleteUser', () {
|
||||
late FakeUserRepository repo;
|
||||
late FakeUserSeeder seeder;
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() async {
|
||||
repo = FakeUserRepository();
|
||||
seeder = FakeUserSeeder();
|
||||
container = _makeContainer(repo: repo, seeder: seeder);
|
||||
await container
|
||||
.read(usersControllerProvider.notifier)
|
||||
.createUser('Alice');
|
||||
});
|
||||
|
||||
tearDown(() => container.dispose());
|
||||
|
||||
test('удаляет пользователя через репозиторий', () async {
|
||||
await container
|
||||
.read(usersControllerProvider.notifier)
|
||||
.deleteUser('uid-0');
|
||||
|
||||
expect(repo.deletedIds, ['uid-0']);
|
||||
expect(container.read(usersControllerProvider), isA<AsyncData<void>>());
|
||||
});
|
||||
|
||||
test('ошибка пробрасывается и пишется в state', () async {
|
||||
final boom = Exception('delete failed');
|
||||
repo.deleteError = boom;
|
||||
|
||||
await expectLater(
|
||||
container
|
||||
.read(usersControllerProvider.notifier)
|
||||
.deleteUser('uid-0'),
|
||||
throwsA(same(boom)),
|
||||
);
|
||||
|
||||
final state = container.read(usersControllerProvider);
|
||||
expect(state, isA<AsyncError<void>>());
|
||||
expect(state.error, same(boom));
|
||||
});
|
||||
});
|
||||
|
||||
group('usersStreamProvider', () {
|
||||
test('пробрасывает значения из репозитория', () async {
|
||||
final repo = FakeUserRepository();
|
||||
final container = _makeContainer(repo: repo, seeder: FakeUserSeeder());
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(usersControllerProvider.notifier)
|
||||
.createUser('Alice');
|
||||
await container
|
||||
.read(usersControllerProvider.notifier)
|
||||
.createUser('Bob');
|
||||
|
||||
// listen, а не read(.future): иначе auto-dispose снесёт провайдер
|
||||
// до того, как future завершится.
|
||||
final sub = container.listen(usersStreamProvider.future, (_, __) {});
|
||||
addTearDown(sub.close);
|
||||
|
||||
final users = await sub.read();
|
||||
expect(users.map((u) => u.name), ['Alice', 'Bob']);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user