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:
@@ -4,6 +4,7 @@
|
||||
"appTitle": "Kitty finances",
|
||||
|
||||
"navHome": "Home",
|
||||
"navInbox": "Inbox",
|
||||
"navAnalytics": "Analytics",
|
||||
"navAccounts": "Accounts",
|
||||
"navProfile": "Profile",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -14,6 +14,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Home';
|
||||
|
||||
@override
|
||||
String get navInbox => 'Inbox';
|
||||
|
||||
@override
|
||||
String get navAnalytics => 'Analytics';
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get navHome => 'Главная';
|
||||
|
||||
@override
|
||||
String get navInbox => 'Входящие';
|
||||
|
||||
@override
|
||||
String get navAnalytics => 'Аналитика';
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"appTitle": "Kitty finances",
|
||||
|
||||
"navHome": "Главная",
|
||||
"navInbox": "Входящие",
|
||||
"navAnalytics": "Аналитика",
|
||||
"navAccounts": "Счета",
|
||||
"navProfile": "Профиль",
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user