Add filter

This commit is contained in:
2026-05-27 17:46:56 +03:00
parent 0284d491f5
commit 8fe4245bd3
15 changed files with 1094 additions and 120 deletions
+2 -1
View File
@@ -4,7 +4,8 @@
"PowerShell(flutter *)",
"Bash(flutter analyze *)",
"Bash(flutter gen-l10n *)",
"Bash(dart run *)"
"Bash(dart run *)",
"Bash(flutter test *)"
]
}
}
+11
View File
@@ -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": {
+24
View File
@@ -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:
+20
View File
@@ -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(
+22
View File
@@ -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(
+11
View File
@@ -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']);
});
});
}