Files
OnBudget/lib/src/app/router/app_router.dart
T
SandersandClaude Opus 4.8 da65bf6f8e Add per-app rules, transfer pairing, self-merchant flag, two-step onboarding
Notification parsing:
- Per-app parse rules (parse_rules.packageName; getEnabledForApp, NULL = legacy global)
- Transfer pairing: transfer_pair_matcher + transfer_pairing_blocklist table/dao/repo
- source_apps.selfMerchant flag (Ozon inbox rework: default account picker, suppress
  AI category prefill + rule suggestion)
- raw_messages.diagnostics dump captured under diagnostic-mode toggle
- Inbox card / settings / log UI reworks

Onboarding:
- Two-step flow (name -> first account); UserSeeder seeds categories only, no accounts

Schema bumped to v11; drop obsolete migration + mixed-merchant tests, add new coverage.
Add ios/ platform folder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:49:04 +03:00

225 lines
8.0 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../core/database/converters/enum_converters.dart';
import '../../features/accounts/presentation/screens/account_form_screen.dart';
import '../../features/accounts/presentation/screens/accounts_screen.dart';
import '../../features/analytics/presentation/screens/analytics_screen.dart';
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/presentation/screens/ai_consent_screen.dart';
import '../../features/notification_parsing/presentation/screens/app_bindings_screen.dart';
import '../../features/notification_parsing/presentation/screens/debug_inject_screen.dart';
import '../../features/notification_parsing/presentation/screens/inbox_screen.dart';
import '../../features/notification_parsing/presentation/screens/parsing_log_screen.dart';
import '../../features/notification_parsing/presentation/screens/parsing_settings_screen.dart';
import '../../features/notification_parsing/presentation/screens/rule_editor_screen.dart';
import '../../features/notification_parsing/presentation/screens/rules_list_screen.dart';
import '../../features/notification_parsing/presentation/screens/source_apps_screen.dart';
import '../../features/profile/presentation/screens/profile_screen.dart';
import '../../features/transactions/presentation/screens/transaction_form_screen.dart';
import '../../features/user/application/active_user_controller.dart';
import '../../features/user/presentation/screens/onboarding_screen.dart';
import '../../shared/widgets/app_scaffold.dart';
import 'app_routes.dart';
part 'app_router.g.dart';
@Riverpod(keepAlive: true)
GoRouter appRouter(Ref ref) {
// ValueNotifier, который дёргается каждый раз, когда меняется состояние
// активного пользователя — это сигнал для GoRouter пересчитать redirect.
final refresh = ValueNotifier<int>(0);
ref.listen<AsyncValue<Object?>>(
activeUserControllerProvider,
(_, _) => refresh.value++,
);
ref.onDispose(refresh.dispose);
return GoRouter(
initialLocation: AppRoutes.home,
refreshListenable: refresh,
redirect: (context, state) {
final active = ref.read(activeUserControllerProvider);
// Пока активный пользователь загружается из БД — никуда не уводим.
if (active.isLoading) return null;
final hasUser = active.value != null;
final atOnboarding = state.matchedLocation == AppRoutes.onboarding;
if (!hasUser) return atOnboarding ? null : AppRoutes.onboarding;
if (atOnboarding) return AppRoutes.home;
return null;
},
routes: [
GoRoute(
path: AppRoutes.onboarding,
builder: (context, state) => const OnboardingScreen(),
),
GoRoute(
path: AppRoutes.transactionNew,
pageBuilder: (context, state) => _slideUpPage<String?>(
state,
TransactionFormScreen(
prefill: state.extra as TransactionFormPrefill?,
),
),
),
GoRoute(
path: AppRoutes.transactionEditPattern,
pageBuilder: (context, state) => _slideUpPage<void>(
state,
TransactionFormScreen(txId: state.pathParameters['id']),
),
),
GoRoute(
path: AppRoutes.categoriesList,
pageBuilder: (context, state) => _slideUpPage<void>(
state,
const CategoriesListScreen(),
),
),
GoRoute(
path: AppRoutes.categoryNew,
pageBuilder: (context, state) => _slideUpPage<String?>(
state,
CategoryFormScreen(presetType: state.extra as CategoryType?),
),
),
GoRoute(
path: AppRoutes.categoryEditPattern,
pageBuilder: (context, state) => _slideUpPage<String?>(
state,
CategoryFormScreen(categoryId: state.pathParameters['id']),
),
),
GoRoute(
path: AppRoutes.accountNew,
pageBuilder: (context, state) => _slideUpPage<String?>(
state,
const AccountFormScreen(),
),
),
GoRoute(
path: AppRoutes.accountEditPattern,
pageBuilder: (context, state) => _slideUpPage<String?>(
state,
AccountFormScreen(accountId: state.pathParameters['id']),
),
),
GoRoute(
path: AppRoutes.inbox,
pageBuilder: (context, state) =>
_slideUpPage<void>(state, const InboxScreen()),
),
GoRoute(
path: AppRoutes.parsingSettings,
builder: (context, state) => const ParsingSettingsScreen(),
),
GoRoute(
path: AppRoutes.parsingAi,
builder: (context, state) => const AiConsentScreen(),
),
GoRoute(
path: AppRoutes.parsingRules,
builder: (context, state) => const RulesListScreen(),
),
// 'new' должен идти раньше ':id', иначе перехватится как id.
GoRoute(
path: AppRoutes.parsingRuleNew,
builder: (context, state) =>
RuleEditorScreen(prefill: state.extra as RuleEditorPrefill?),
),
GoRoute(
path: AppRoutes.parsingRuleEditPattern,
builder: (context, state) =>
RuleEditorScreen(ruleId: state.pathParameters['id']),
),
GoRoute(
path: AppRoutes.parsingDebugInject,
builder: (context, state) => const DebugInjectScreen(),
),
GoRoute(
path: AppRoutes.parsingLog,
builder: (context, state) => const ParsingLogScreen(),
),
GoRoute(
path: AppRoutes.parsingApps,
builder: (context, state) => const SourceAppsScreen(),
),
GoRoute(
path: AppRoutes.parsingAppBindingsPattern,
builder: (context, state) => AppBindingsScreen(
packageName: Uri.decodeComponent(state.pathParameters['pkg']!),
),
),
GoRoute(
path: AppRoutes.habitAnalysis,
pageBuilder: (context, state) =>
_slideUpPage<void>(state, const HabitAnalysisScreen()),
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => AppScaffold(
navigationShell: navigationShell,
),
branches: [
StatefulShellBranch(
routes: [
GoRoute(
path: AppRoutes.home,
builder: (context, state) => const HomeScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: AppRoutes.analytics,
builder: (context, state) => const AnalyticsScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: AppRoutes.accounts,
builder: (context, state) => const AccountsScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: AppRoutes.profile,
builder: (context, state) => const ProfileScreen(),
),
],
),
],
),
],
);
}
Page<T> _slideUpPage<T>(GoRouterState state, Widget child) {
return CustomTransitionPage<T>(
key: state.pageKey,
child: child,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(curved),
child: child,
);
},
);
}