Move Inbox to a dedicated bottom-nav tab gated by parsing toggle

Replace the Inbox badge button in MonthHeader with a bottom-nav tab
(hidden when notification parsing is disabled). Redirect /inbox → /home
when parsing is off, register the Inbox shell branch unconditionally,
and add a settings action to the Inbox screen. Move the parsing tile to
the top of the profile screen so settings stay reachable when the tab
is hidden. Add tests for the nav tab visibility and inbox routing, plus
a release-install PowerShell helper that preserves app data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 16:45:21 +03:00
co-authored by Claude Opus 4.8
parent 4f99b80169
commit fdc5c14852
14 changed files with 348 additions and 50 deletions
+1
View File
@@ -4,6 +4,7 @@
"appTitle": "Kitty finances",
"navHome": "Home",
"navInbox": "Inbox",
"navAnalytics": "Analytics",
"navAccounts": "Accounts",
"navProfile": "Profile",
+6
View File
@@ -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:
+3
View File
@@ -14,6 +14,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get navHome => 'Home';
@override
String get navInbox => 'Inbox';
@override
String get navAnalytics => 'Analytics';
+3
View File
@@ -14,6 +14,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get navHome => 'Главная';
@override
String get navInbox => 'Входящие';
@override
String get navAnalytics => 'Аналитика';
+1
View File
@@ -4,6 +4,7 @@
"appTitle": "Kitty finances",
"navHome": "Главная",
"navInbox": "Входящие",
"navAnalytics": "Аналитика",
"navAccounts": "Счета",
"navProfile": "Профиль",
+24 -4
View File
@@ -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<AsyncValue<Object?>>(
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(
@@ -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,
@@ -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: [
@@ -64,6 +64,14 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
),
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<ProfileScreen> {
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),
),
],
),
),
+48 -10
View File
@@ -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<int> 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(
+14
View File
@@ -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,
),
);
}
+109
View File
@@ -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<Text>()) {
// 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);
});
}
@@ -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<int>? 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 = <int>[];
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<Text>(find.text('Analytics')).style?.fontWeight;
final homeLabel = tester.widget<Text>(find.text('Home')).style?.fontWeight;
expect(analyticsLabel, FontWeight.w600);
expect(homeLabel, FontWeight.w400);
});
}
+41
View File
@@ -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