From 8fe4245bd3dc875a6c1cf15e36e8e3b1aba81a71 Mon Sep 17 00:00:00 2001 From: Sanders Date: Wed, 27 May 2026 17:46:56 +0300 Subject: [PATCH] Add filter --- .claude/settings.local.json | 3 +- lib/l10n/app_en.arb | 11 + lib/l10n/app_localizations.dart | 24 + lib/l10n/app_localizations_en.dart | 20 + lib/l10n/app_localizations_ru.dart | 22 + lib/l10n/app_ru.arb | 11 + .../home/presentation/month_summary.dart | 132 ++++-- .../state/selected_category_filter.dart | 46 +- .../presentation/widgets/category_donut.dart | 22 +- .../widgets/category_donut_card.dart | 6 +- .../presentation/widgets/month_header.dart | 76 +++- .../widgets/transactions_section.dart | 424 ++++++++++++++++-- .../user/application/users_controller.dart | 57 ++- .../screens/onboarding_screen.dart | 6 + test/features/user/users_controller_test.dart | 354 +++++++++++++++ 15 files changed, 1094 insertions(+), 120 deletions(-) create mode 100644 test/features/user/users_controller_test.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0948644..79ef353 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "PowerShell(flutter *)", "Bash(flutter analyze *)", "Bash(flutter gen-l10n *)", - "Bash(dart run *)" + "Bash(dart run *)", + "Bash(flutter test *)" ] } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3b54253..05fad98 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 120848a..c4bb014 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index be49d91..be4a762 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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( diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 2cf4c57..ae56be8 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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( diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 995d634..fb7a9a5 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -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": { diff --git a/lib/src/features/home/presentation/month_summary.dart b/lib/src/features/home/presentation/month_summary.dart index 4bedde0..d26b40e 100644 --- a/lib/src/features/home/presentation/month_summary.dart +++ b/lib/src/features/home/presentation/month_summary.dart @@ -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 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 []; + 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 []; - 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 []; + final selectedAccount = ref.watch(selectedAccountProvider); - final scopedTxs = selectedAccount.isEmpty - ? txs - : txs.where((t) => t.accountId == selectedAccount).toList(); + // При конкретном счёте включаем также входящие переводы + final Iterable 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 = {}; + 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( - 0, - (s, Account a) => s + a.initialBalance, - ); - return MonthSummary( - balanceMinor: baseBalance + income - expense, + balanceMinor: income - expense, // итог за месяц (не накопленный баланс) incomeMinor: income, expensesMinor: expense, spendByCategory: spendByCat, diff --git a/lib/src/features/home/presentation/state/selected_category_filter.dart b/lib/src/features/home/presentation/state/selected_category_filter.dart index 74c05fc..f029bf2 100644 --- a/lib/src/features/home/presentation/state/selected_category_filter.dart +++ b/lib/src/features/home/presentation/state/selected_category_filter.dart @@ -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 build() => const {}; - void select(String? id) => state = id; + /// Добавить/убрать категорию из выбранных. + void toggle(String id) { + final next = Set.from(state); + if (next.contains(id)) { + next.remove(id); + } else { + next.add(id); + } + state = next; + } + + /// Снять фильтр целиком. + void clear() => state = const {}; + + /// Заменить всё множество разом (используется в «Выбрать все»). + void setAll(Set 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); + } +} diff --git a/lib/src/features/home/presentation/widgets/category_donut.dart b/lib/src/features/home/presentation/widgets/category_donut.dart index 28dc491..9cc2fb5 100644 --- a/lib/src/features/home/presentation/widgets/category_donut.dart +++ b/lib/src/features/home/presentation/widgets/category_donut.dart @@ -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 slices; final double size; final double thickness; - final String? activeId; - final ValueChanged? onSegmentTap; + + /// Множество активных (выбранных) id. Пустое = ни один не выбран (все ярко). + final Set activeIds; + + /// Колбэк с id нажатого сегмента. + final ValueChanged? 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); } + // Тап вне сегмента игнорируем — сброс только через пилюлю } }, ), diff --git a/lib/src/features/home/presentation/widgets/category_donut_card.dart b/lib/src/features/home/presentation/widgets/category_donut_card.dart index 8688cc6..8f297df 100644 --- a/lib/src/features/home/presentation/widgets/category_donut_card.dart +++ b/lib/src/features/home/presentation/widgets/category_donut_card.dart @@ -24,7 +24,7 @@ class CategoryDonutCard extends ConsumerWidget { final categories = ref.watch(categoriesStreamProvider(userId)).value ?? const []; - 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, diff --git a/lib/src/features/home/presentation/widgets/month_header.dart b/lib/src/features/home/presentation/widgets/month_header.dart index fd42c22..743c704 100644 --- a/lib/src/features/home/presentation/widgets/month_header.dart +++ b/lib/src/features/home/presentation/widgets/month_header.dart @@ -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), + ), + ); + } +} diff --git a/lib/src/features/home/presentation/widgets/transactions_section.dart b/lib/src/features/home/presentation/widgets/transactions_section.dart index 1ce8e7b..7c8d1a6 100644 --- a/lib/src/features/home/presentation/widgets/transactions_section.dart +++ b/lib/src/features/home/presentation/widgets/transactions_section.dart @@ -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 []; - 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 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 categories, +) { + showModalBottomSheet( + 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 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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/features/user/application/users_controller.dart b/lib/src/features/user/application/users_controller.dart index b8cb67f..699d410 100644 --- a/lib/src/features/user/application/users_controller.dart +++ b/lib/src/features/user/application/users_controller.dart @@ -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> 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 build() => const AsyncData(null); Future 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 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 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; + } } } diff --git a/lib/src/features/user/presentation/screens/onboarding_screen.dart b/lib/src/features/user/presentation/screens/onboarding_screen.dart index ada2b78..d89e010 100644 --- a/lib/src/features/user/presentation/screens/onboarding_screen.dart +++ b/lib/src/features/user/presentation/screens/onboarding_screen.dart @@ -34,6 +34,12 @@ class _OnboardingScreenState extends ConsumerState { .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); } diff --git a/test/features/user/users_controller_test.dart b/test/features/user/users_controller_test.dart new file mode 100644 index 0000000..bc074e4 --- /dev/null +++ b/test/features/user/users_controller_test.dart @@ -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 _users = []; + final List createdNames = []; + final List deletedIds = []; + final List<(String, String)> renames = []; + + Object? createError; + Object? renameError; + Object? deleteError; + + /// Контролируемая задержка create — позволяет проверить, что между + /// state=AsyncLoading и финальным state notifier остаётся жив. + Completer? createGate; + + @override + Stream> watchAll() => Stream.value(List.unmodifiable(_users)); + + @override + Future findById(String id) async => + _users.cast().firstWhere((u) => u!.id == id, orElse: () => null); + + @override + Future 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 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 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 seededFor = []; + Object? seedError; + + @override + Future 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>()); + 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(); + repo.createGate = gate; + + final notifier = container.read(usersControllerProvider.notifier); + final future = notifier.createUser('Alice'); + + // Уступаем event loop, чтобы auto-dispose (если бы он был) сработал. + await Future.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(); + repo.createGate = gate; + + final future = container + .read(usersControllerProvider.notifier) + .createUser('Alice'); + + // Даём контроллеру дойти до первого await. + await Future.delayed(Duration.zero); + expect(container.read(usersControllerProvider), isA>()); + + 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>()); + 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>()); + 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())); + + 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>()); + }); + + 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>()); + 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>()); + }); + + 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>()); + 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']); + }); + }); +}