diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6f1c4c0..ab3c2c5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -4,6 +4,7 @@ "appTitle": "Kitty finances", "navHome": "Home", + "navInbox": "Inbox", "navAnalytics": "Analytics", "navAccounts": "Accounts", "navProfile": "Profile", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a70bbdb..4e2d2fd 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -110,6 +110,12 @@ abstract class AppLocalizations { /// **'Главная'** String get navHome; + /// No description provided for @navInbox. + /// + /// In ru, this message translates to: + /// **'Входящие'** + String get navInbox; + /// No description provided for @navAnalytics. /// /// In ru, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 87fa48e..e49f3dd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -14,6 +14,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get navHome => 'Home'; + @override + String get navInbox => 'Inbox'; + @override String get navAnalytics => 'Analytics'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 0fd9fc5..e3c45ea 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -14,6 +14,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get navHome => 'Главная'; + @override + String get navInbox => 'Входящие'; + @override String get navAnalytics => 'Аналитика'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6a9c58e..3acf8d6 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -4,6 +4,7 @@ "appTitle": "Kitty finances", "navHome": "Главная", + "navInbox": "Входящие", "navAnalytics": "Аналитика", "navAccounts": "Счета", "navProfile": "Профиль", diff --git a/lib/src/app/router/app_router.dart b/lib/src/app/router/app_router.dart index 56d8ecb..8fd2ab2 100644 --- a/lib/src/app/router/app_router.dart +++ b/lib/src/app/router/app_router.dart @@ -10,6 +10,7 @@ import '../../features/analytics/presentation/screens/habit_analysis_screen.dart import '../../features/categories/presentation/screens/categories_list_screen.dart'; import '../../features/categories/presentation/screens/category_form_screen.dart'; import '../../features/home/presentation/screens/home_screen.dart'; +import '../../features/notification_parsing/application/parsing_settings_controller.dart'; import '../../features/notification_parsing/presentation/screens/ai_consent_screen.dart'; import '../../features/notification_parsing/presentation/screens/debug_inject_screen.dart'; import '../../features/notification_parsing/presentation/screens/inbox_screen.dart'; @@ -37,6 +38,12 @@ GoRouter appRouter(Ref ref) { activeUserControllerProvider, (_, _) => refresh.value++, ); + // Смена мастер-тумблера парсинга тоже влияет на redirect (/inbox → /home, + // когда парсинг выключили, находясь на вкладке Inbox). + ref.listen>( + parsingSettingsControllerProvider, + (_, _) => refresh.value++, + ); ref.onDispose(refresh.dispose); return GoRouter( @@ -50,6 +57,13 @@ GoRouter appRouter(Ref ref) { final atOnboarding = state.matchedLocation == AppRoutes.onboarding; if (!hasUser) return atOnboarding ? null : AppRoutes.onboarding; if (atOnboarding) return AppRoutes.home; + // `?? true` — тот же дефолт, что у ParsingSettings.enabled, чтобы кадр + // первой загрузки не расходился с видимостью вкладки в AppBottomNav. + final parsingEnabled = + ref.read(parsingSettingsControllerProvider).value?.enabled ?? true; + if (!parsingEnabled && state.matchedLocation == AppRoutes.inbox) { + return AppRoutes.home; + } return null; }, routes: [ @@ -105,10 +119,6 @@ GoRouter appRouter(Ref ref) { AccountFormScreen(accountId: state.pathParameters['id']), ), ), - GoRoute( - path: AppRoutes.inbox, - builder: (context, state) => const InboxScreen(), - ), GoRoute( path: AppRoutes.parsingSettings, builder: (context, state) => const ParsingSettingsScreen(), @@ -167,6 +177,16 @@ GoRouter appRouter(Ref ref) { ), ], ), + // Ветка Inbox зарегистрирована всегда; при выключенном парсинге + // скрывается только пункт навбара, а redirect уводит /inbox → /home. + StatefulShellBranch( + routes: [ + GoRoute( + path: AppRoutes.inbox, + builder: (context, state) => const InboxScreen(), + ), + ], + ), StatefulShellBranch( routes: [ GoRoute( diff --git a/lib/src/features/home/presentation/widgets/month_header.dart b/lib/src/features/home/presentation/widgets/month_header.dart index 6f76e63..a4e6def 100644 --- a/lib/src/features/home/presentation/widgets/month_header.dart +++ b/lib/src/features/home/presentation/widgets/month_header.dart @@ -1,13 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../../../app/l10n/l10n.dart'; -import '../../../../app/router/app_routes.dart'; import '../../../../app/theme/app_colors.dart'; -import '../../../notification_parsing/application/inbox_controller.dart'; -import '../../../user/application/active_user_controller.dart'; import '../state/selected_category_filter.dart'; class MonthHeader extends ConsumerWidget { @@ -85,38 +81,12 @@ class MonthHeader extends ConsumerWidget { constraints: const BoxConstraints(minWidth: 32, minHeight: 32), icon: Icon(Icons.search, size: 20, color: p.ink2), ), - const SizedBox(width: 4), - _InboxButton(color: p.ink2), ], ), ); } } -/// Кнопка-бэдж Inbox: ✉N появляется при наличии непросмотренных уведомлений. -class _InboxButton extends ConsumerWidget { - const _InboxButton({required this.color}); - - final Color color; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final userId = ref.watch(activeUserControllerProvider).value?.id; - final count = - userId == null ? 0 : (ref.watch(inboxCountProvider(userId)).value ?? 0); - - final button = IconButton( - onPressed: () => context.push(AppRoutes.inbox), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - icon: Icon(Icons.notifications_outlined, size: 20, color: color), - ); - - if (count == 0) return button; - return Badge.count(count: count, child: button); - } -} - class _NavButton extends StatelessWidget { const _NavButton({ required this.icon, diff --git a/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart b/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart index 695477d..d56bb02 100644 --- a/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/inbox_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import '../../../../app/l10n/l10n.dart'; +import '../../../../app/router/app_routes.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../accounts/application/accounts_controller.dart'; import '../../../categories/application/categories_controller.dart'; @@ -42,6 +44,12 @@ class InboxScreen extends ConsumerWidget { ? l10n.inboxTitle : '${l10n.inboxTitle} (${messages.length})', ), + actions: [ + IconButton( + icon: Icon(Icons.settings_outlined, color: p.ink2), + onPressed: () => context.push(AppRoutes.parsingSettings), + ), + ], ), body: Column( children: [ diff --git a/lib/src/features/profile/presentation/screens/profile_screen.dart b/lib/src/features/profile/presentation/screens/profile_screen.dart index 5cd6a8c..fe91cfb 100644 --- a/lib/src/features/profile/presentation/screens/profile_screen.dart +++ b/lib/src/features/profile/presentation/screens/profile_screen.dart @@ -64,6 +64,14 @@ class _ProfileScreenState extends ConsumerState { ), child: Column( children: [ + // Плитка парсинга — сверху: это единственный вход в его + // настройки, когда парсинг выключен и вкладки Inbox нет. + _NavRow( + icon: Icons.notifications_active_outlined, + title: l10n.profileParsingTile, + onTap: () => context.push(AppRoutes.parsingSettings), + ), + Container(height: 1, color: p.line), _Row( icon: Icons.dark_mode_outlined, title: l10n.darkTheme, @@ -107,12 +115,6 @@ class _ProfileScreenState extends ConsumerState { title: l10n.profileCategoriesTile, onTap: () => context.push(AppRoutes.categoriesList), ), - Container(height: 1, color: p.line), - _NavRow( - icon: Icons.notifications_active_outlined, - title: l10n.profileParsingTile, - onTap: () => context.push(AppRoutes.parsingSettings), - ), ], ), ), diff --git a/lib/src/shared/widgets/app_bottom_nav.dart b/lib/src/shared/widgets/app_bottom_nav.dart index f6a35f8..933a80d 100644 --- a/lib/src/shared/widgets/app_bottom_nav.dart +++ b/lib/src/shared/widgets/app_bottom_nav.dart @@ -8,20 +8,48 @@ class AppBottomNav extends StatelessWidget { super.key, required this.activeIndex, required this.onTap, + required this.showInbox, + required this.inboxCount, }); + /// Индекс активной ветки шелла (не позиция видимого пункта). final int activeIndex; final ValueChanged onTap; + /// Вкладка Inbox видна только при включённом парсинге уведомлений. + final bool showInbox; + final int inboxCount; + @override Widget build(BuildContext context) { final p = context.palette; final l10n = context.l10n; + // branchIndex — позиция ветки в StatefulShellRoute (app_router.dart); + // при скрытом Inbox видимые позиции и индексы веток расходятся. final items = <_NavItem>[ - _NavItem(icon: Icons.home_outlined, label: l10n.navHome), - _NavItem(icon: Icons.bar_chart_outlined, label: l10n.navAnalytics), - _NavItem(icon: Icons.account_balance_wallet_outlined, label: l10n.navAccounts), - _NavItem(icon: Icons.person_outline, label: l10n.navProfile), + _NavItem(branchIndex: 0, icon: Icons.home_outlined, label: l10n.navHome), + if (showInbox) + _NavItem( + branchIndex: 1, + icon: Icons.inbox_outlined, + label: l10n.navInbox, + badgeCount: inboxCount, + ), + _NavItem( + branchIndex: 2, + icon: Icons.bar_chart_outlined, + label: l10n.navAnalytics, + ), + _NavItem( + branchIndex: 3, + icon: Icons.account_balance_wallet_outlined, + label: l10n.navAccounts, + ), + _NavItem( + branchIndex: 4, + icon: Icons.person_outline, + label: l10n.navProfile, + ), ]; return Container( @@ -35,12 +63,12 @@ class AppBottomNav extends StatelessWidget { height: 60, child: Row( children: [ - for (var i = 0; i < items.length; i++) + for (final item in items) Expanded( child: _NavTab( - item: items[i], - active: i == activeIndex, - onTap: () => onTap(i), + item: item, + active: item.branchIndex == activeIndex, + onTap: () => onTap(item.branchIndex), ), ), ], @@ -52,9 +80,16 @@ class AppBottomNav extends StatelessWidget { } class _NavItem { - const _NavItem({required this.icon, required this.label}); + const _NavItem({ + required this.branchIndex, + required this.icon, + required this.label, + this.badgeCount = 0, + }); + final int branchIndex; final IconData icon; final String label; + final int badgeCount; } class _NavTab extends StatelessWidget { @@ -72,6 +107,7 @@ class _NavTab extends StatelessWidget { Widget build(BuildContext context) { final p = context.palette; final color = active ? p.accent : p.ink2; + final icon = Icon(item.icon, size: 22, color: color); return InkWell( onTap: onTap, child: Padding( @@ -89,7 +125,9 @@ class _NavTab extends StatelessWidget { color: active ? p.accentSoft : Colors.transparent, borderRadius: BorderRadius.circular(12), ), - child: Icon(item.icon, size: 22, color: color), + child: item.badgeCount > 0 + ? Badge.count(count: item.badgeCount, child: icon) + : icon, ), const SizedBox(height: 3), Text( diff --git a/lib/src/shared/widgets/app_scaffold.dart b/lib/src/shared/widgets/app_scaffold.dart index dbdaf45..20c4e9c 100644 --- a/lib/src/shared/widgets/app_scaffold.dart +++ b/lib/src/shared/widgets/app_scaffold.dart @@ -3,7 +3,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../features/notification_parsing/application/inbox_controller.dart'; import '../../features/notification_parsing/application/notification_ingest_worker.dart'; +import '../../features/notification_parsing/application/parsing_settings_controller.dart'; import '../../features/notification_parsing/application/parsing_worker.dart'; import '../../features/user/application/active_user_controller.dart'; import 'app_bottom_nav.dart'; @@ -28,6 +30,16 @@ class AppScaffold extends ConsumerWidget { } } + // `?? true` совпадает с дефолтом ParsingSettings.enabled — без мигания + // вкладки на кадре первой загрузки настроек (см. redirect в app_router). + final showInbox = + ref.watch(parsingSettingsControllerProvider).value?.enabled ?? true; + // Watch на уровне шелла заодно держит autoDispose-стрим счётчика живым + // на всех вкладках (раньше это делал колокольчик в MonthHeader). + final inboxCount = userId == null + ? 0 + : (ref.watch(inboxCountProvider(userId)).value ?? 0); + return Scaffold( body: navigationShell, bottomNavigationBar: AppBottomNav( @@ -36,6 +48,8 @@ class AppScaffold extends ConsumerWidget { i, initialLocation: i == navigationShell.currentIndex, ), + showInbox: showInbox, + inboxCount: inboxCount, ), ); } diff --git a/test/app/router/inbox_tab_test.dart b/test/app/router/inbox_tab_test.dart new file mode 100644 index 0000000..89f5032 --- /dev/null +++ b/test/app/router/inbox_tab_test.dart @@ -0,0 +1,109 @@ +import 'package:drift/native.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:new_budget/src/app/app.dart'; +import 'package:new_budget/src/app/router/app_router.dart'; +import 'package:new_budget/src/app/router/app_routes.dart'; +import 'package:new_budget/src/core/database/app_database.dart'; +import 'package:new_budget/src/core/providers/database_provider.dart'; +import 'package:new_budget/src/features/notification_parsing/application/notification_ingest_worker.dart'; +import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart'; +import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart'; +import 'package:new_budget/src/features/notification_parsing/presentation/screens/inbox_screen.dart'; + +// Воркеры парсинга — no-op: реальные подписываются на connectivity_plus и +// нативный notification-listener канал, которых под `flutter test` нет. +class _FakeParsingWorker extends ParsingWorker { + @override + void build(String userId) {} +} + +class _FakeIngestWorker extends NotificationIngestWorker { + @override + void build(String userId) {} +} + +void main() { + setUpAll(() { + GoogleFonts.config.allowRuntimeFetching = false; + }); + + testWidgets( + 'вкладка Inbox видна только при включённом парсинге, /inbox редиректит', + (tester) async { + await initializeDateFormatting('ru'); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + + // Реальная in-memory БД: активный пользователь + выключенный парсинг. + await db.usersDao + .insertUser(UsersTableCompanion.insert(id: 'u1', name: 'Тест')); + await db.settingsDao.setPreference('active_user_id', 'u1'); + await db.settingsDao.setPreference('parsing_enabled', 'false'); + + final container = ProviderContainer( + overrides: [ + appDatabaseProvider.overrideWithValue(db), + parsingWorkerProvider.overrideWith2((_) => _FakeParsingWorker()), + notificationIngestWorkerProvider + .overrideWith2((_) => _FakeIngestWorker()), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const NewBudgetApp(), + ), + ); + // Пара кадров: активный пользователь и настройки парсинга грузятся из БД. + await tester.pump(); + await tester.pump(); + await tester.pump(); + + // ignore: avoid_print + for (final t in tester.allWidgets.whereType()) { + // ignore: avoid_print + print('TEXT: ${t.data}'); + } + + // Парсинг выключен → вкладки Inbox нет, остальные на месте. + expect(find.text('Inbox'), findsNothing); + expect(find.text('Home'), findsOneWidget); + + // Deep-link на /inbox при выключенном парсинге уводит на /home. + final router = container.read(appRouterProvider); + router.go(AppRoutes.inbox); + await tester.pump(); + await tester.pump(); + expect(router.state.matchedLocation, AppRoutes.home); + expect(find.byType(InboxScreen), findsNothing); + + // Включаем парсинг — вкладка появляется без пересоздания роутера. + await container + .read(parsingSettingsControllerProvider.notifier) + .setEnabled(true); + await tester.pump(); + expect(find.text('Inbox'), findsOneWidget); + + // Теперь /inbox доступен. + await tester.tap(find.text('Inbox')); + await tester.pump(); + await tester.pump(); + expect(router.state.matchedLocation, AppRoutes.inbox); + expect(find.byType(InboxScreen), findsOneWidget); + + // Выключаем парсинг, находясь НА вкладке Inbox: redirect возвращает + // на /home (refreshListenable дёргается слушателем настроек). + await container + .read(parsingSettingsControllerProvider.notifier) + .setEnabled(false); + await tester.pump(); + await tester.pump(); + expect(router.state.matchedLocation, AppRoutes.home); + expect(find.text('Inbox'), findsNothing); + }); +} diff --git a/test/shared/widgets/app_bottom_nav_test.dart b/test/shared/widgets/app_bottom_nav_test.dart new file mode 100644 index 0000000..fb6b3b5 --- /dev/null +++ b/test/shared/widgets/app_bottom_nav_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:new_budget/l10n/app_localizations.dart'; +import 'package:new_budget/src/app/theme/app_theme.dart'; +import 'package:new_budget/src/shared/widgets/app_bottom_nav.dart'; + +Widget _build({ + required bool showInbox, + int inboxCount = 0, + int activeIndex = 0, + ValueChanged? onTap, +}) => + MaterialApp( + theme: AppTheme.light(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + bottomNavigationBar: AppBottomNav( + activeIndex: activeIndex, + onTap: onTap ?? (_) {}, + showInbox: showInbox, + inboxCount: inboxCount, + ), + ), + ); + +void main() { + setUpAll(() { + GoogleFonts.config.allowRuntimeFetching = false; + }); + + testWidgets('при включённом парсинге видны все 5 вкладок', (tester) async { + await tester.pumpWidget(_build(showInbox: true)); + + expect(find.text('Home'), findsOneWidget); + expect(find.text('Inbox'), findsOneWidget); + expect(find.text('Analytics'), findsOneWidget); + expect(find.text('Accounts'), findsOneWidget); + expect(find.text('Profile'), findsOneWidget); + }); + + testWidgets('при выключенном парсинге вкладки Inbox нет', (tester) async { + await tester.pumpWidget(_build(showInbox: false)); + + expect(find.text('Inbox'), findsNothing); + expect(find.text('Home'), findsOneWidget); + expect(find.text('Profile'), findsOneWidget); + }); + + testWidgets('бэдж показывает счётчик и исчезает при нуле', (tester) async { + await tester.pumpWidget(_build(showInbox: true, inboxCount: 7)); + expect(find.byType(Badge), findsOneWidget); + expect(find.text('7'), findsOneWidget); + + await tester.pumpWidget(_build(showInbox: true, inboxCount: 0)); + expect(find.byType(Badge), findsNothing); + }); + + testWidgets('тап отдаёт индекс ВЕТКИ, а не видимую позицию', (tester) async { + final tapped = []; + await tester.pumpWidget(_build(showInbox: false, onTap: tapped.add)); + + // Inbox скрыт: «Аналитика» — вторая видимая, но её ветка — 2. + await tester.tap(find.text('Analytics')); + expect(tapped, [2]); + + await tester.tap(find.text('Profile')); + expect(tapped, [2, 4]); + }); + + testWidgets('активная вкладка определяется по индексу ветки', (tester) async { + // activeIndex = 2 (аналитика) при скрытом Inbox: подсвечена именно она. + await tester.pumpWidget(_build(showInbox: false, activeIndex: 2)); + + final analyticsLabel = + tester.widget(find.text('Analytics')).style?.fontWeight; + final homeLabel = tester.widget(find.text('Home')).style?.fontWeight; + expect(analyticsLabel, FontWeight.w600); + expect(homeLabel, FontWeight.w400); + }); +} diff --git a/tool/install_release.ps1 b/tool/install_release.ps1 new file mode 100644 index 0000000..ef51fc8 --- /dev/null +++ b/tool/install_release.ps1 @@ -0,0 +1,41 @@ +# Ставит release-сборку на устройство БЕЗ удаления приложения (данные сохраняются). +# НЕ использовать `flutter install` — он всегда делает "Uninstalling old version..." +# и стирает данные (захардкожено в flutter_tools/lib/src/commands/install.dart). +# +# Использование: +# tool\install_release.ps1 # собрать и поставить +# tool\install_release.ps1 -NoBuild # только поставить уже собранный APK +# tool\install_release.ps1 -Device ca9a0ff7 + +param( + [string]$Device, + [switch]$NoBuild +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot + +if (-not $NoBuild) { + Push-Location $root + try { + flutter build apk --release + if ($LASTEXITCODE -ne 0) { throw "flutter build apk failed" } + } finally { + Pop-Location + } +} + +$apk = Join-Path $root "build\app\outputs\flutter-apk\app-release.apk" +if (-not (Test-Path $apk)) { throw "APK not found: $apk (run without -NoBuild)" } + +$sdk = $env:ANDROID_HOME +if (-not $sdk) { $sdk = "$env:LOCALAPPDATA\Android\sdk" } +$adb = Join-Path $sdk "platform-tools\adb.exe" +if (-not (Test-Path $adb)) { throw "adb not found: $adb" } + +$adbArgs = @() +if ($Device) { $adbArgs += @("-s", $Device) } +# -r = replace: обновление поверх, данные приложения не трогаются +& $adb @adbArgs install -r $apk +if ($LASTEXITCODE -ne 0) { throw "adb install failed" } +Write-Host "Installed in place, app data preserved." -ForegroundColor Green