From fdc5c14852631adc965cbc18f895238ebf04d27c Mon Sep 17 00:00:00 2001 From: Aleksandr Mineev Date: Sun, 19 Jul 2026 16:45:21 +0300 Subject: [PATCH] Move Inbox to a dedicated bottom-nav tab gated by parsing toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_ru.dart | 3 + lib/l10n/app_ru.arb | 1 + lib/src/app/router/app_router.dart | 28 ++++- .../presentation/widgets/month_header.dart | 30 ----- .../presentation/screens/inbox_screen.dart | 8 ++ .../presentation/screens/profile_screen.dart | 14 ++- lib/src/shared/widgets/app_bottom_nav.dart | 58 ++++++++-- lib/src/shared/widgets/app_scaffold.dart | 14 +++ test/app/router/inbox_tab_test.dart | 109 ++++++++++++++++++ test/shared/widgets/app_bottom_nav_test.dart | 82 +++++++++++++ tool/install_release.ps1 | 41 +++++++ 14 files changed, 348 insertions(+), 50 deletions(-) create mode 100644 test/app/router/inbox_tab_test.dart create mode 100644 test/shared/widgets/app_bottom_nav_test.dart create mode 100644 tool/install_release.ps1 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