From 5cb121d7337ed2bbedd23f100cfabb3bee8c5990 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 16 Mar 2026 10:32:12 +0300 Subject: [PATCH 01/25] fat(recipes, diets): added list refreshing --- .../presentation/create_diet_screen.dart | 10 +++++----- .../pages/recipe_create_screen.dart | 4 ++-- .../my_diets/presentation/my_diets_screen.dart | 12 +++++++++++- .../presentation/widgets/my_diet_widget.dart | 18 +++++++++++++++--- .../presentation/pages/my_recipes_screen.dart | 10 ++++++++-- .../presentation/widgets/my_recipe_widget.dart | 16 ++++++++++++++-- 6 files changed, 55 insertions(+), 15 deletions(-) diff --git a/lib/features/create_diet/presentation/create_diet_screen.dart b/lib/features/create_diet/presentation/create_diet_screen.dart index 5d95e783..c04f1e1c 100644 --- a/lib/features/create_diet/presentation/create_diet_screen.dart +++ b/lib/features/create_diet/presentation/create_diet_screen.dart @@ -103,7 +103,7 @@ class _CreateDietScreenContentState extends State { if (state.published) { Future.delayed(const Duration(milliseconds: 500), () { if (!context.mounted) return; - context.pop(); + context.pop(true); showBottomSheetWithBlur( userRootNavigator: true, context: context, @@ -113,7 +113,7 @@ class _CreateDietScreenContentState extends State { } else if (state.drafted) { Future.delayed(const Duration(milliseconds: 500), () { if (!context.mounted) return; - context.pop(); + context.pop(true); showBottomSheetWithBlur( context: context, child: SuccessPublishedDietOrPlanBottomSheet(title: state.dietData.name ?? 'Диета', isDrafted: true), @@ -231,7 +231,7 @@ class _CreateDietScreenContentState extends State { if (dietState == DietState.draft) { Future.delayed(const Duration(milliseconds: 500), () { if (!context.mounted) return; - context.pop(); + context.pop(true); showBottomSheetWithBlur( userRootNavigator: true, context: context, @@ -241,7 +241,7 @@ class _CreateDietScreenContentState extends State { } else { Future.delayed(const Duration(milliseconds: 500), () { if (!context.mounted) return; - context.pop(); + context.pop(true); showBottomSheetWithBlur( context: context, child: SuccessPublishedDietOrPlanBottomSheet(title: state.dietData.name ?? 'Диета', isDrafted: true), @@ -304,7 +304,7 @@ class _CreateDietScreenContentState extends State { } else { Future.delayed(const Duration(milliseconds: 500), () { if (!context.mounted) return; - context.pop(); + context.pop(true); showBottomSheetWithBlur( userRootNavigator: true, context: context, diff --git a/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart b/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart index a749186d..d4b622aa 100644 --- a/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart +++ b/lib/features/create_recipe/presentation/pages/recipe_create_screen.dart @@ -109,7 +109,7 @@ class _RecipeCreateScreenState extends State { if (isSuccess) { Future.delayed(const Duration(milliseconds: 500), () { if (!context.mounted) return; - context.pop(); + context.pop('active'); showBottomSheetWithBlur( userRootNavigator: true, context: context, @@ -157,7 +157,7 @@ class _RecipeCreateScreenState extends State { }); if (isSuccess) { if (!context.mounted) return; - context.pop(); + context.pop('drafted'); } else { if (!context.mounted) return; showErrorDialog(context, title: 'Не удалось сохранить черновик'); diff --git a/lib/features/my_diets/presentation/my_diets_screen.dart b/lib/features/my_diets/presentation/my_diets_screen.dart index 1c5bad54..25662b1c 100644 --- a/lib/features/my_diets/presentation/my_diets_screen.dart +++ b/lib/features/my_diets/presentation/my_diets_screen.dart @@ -107,7 +107,17 @@ class MyDietsScreenContent extends StatelessWidget { ), ), const SizedBox(width: 12), - AddItemButton(onTap: () => GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false})), + AddItemButton( + onTap: () async { + final result = await GoRouter.of(context).pushNamed( + CreateDietScreen.name, + extra: {'isEditing': false}, + ); + if (!context.mounted) return; + if (result == true) { + await context.read().loadAllDiets(); + } + }), const SizedBox(width: 4), ], ), diff --git a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart index 0799fc77..e2236b96 100644 --- a/lib/features/my_diets/presentation/widgets/my_diet_widget.dart +++ b/lib/features/my_diets/presentation/widgets/my_diet_widget.dart @@ -38,9 +38,14 @@ class MyDietWidget extends StatefulWidget { class _MyDietWidgetState extends State { Future _editDiet(Diet diet, BuildContext context) async{ - await GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'diet': diet, 'isEditing': true}); + final result = await GoRouter.of(context).pushNamed( + CreateDietScreen.name, + extra: {'diet': diet, 'isEditing': true}, + ); if (!context.mounted) return; - await context.read().loadAllDiets(); + if (result == true) { + await context.read().loadAllDiets(); + } } void _suggestDiet(Diet diet) { @@ -88,7 +93,14 @@ class _MyDietWidgetState extends State { void _createBasedOnChosen(Diet diet, BuildContext context) async { await showErrorDialog(context, title: 'Предупреждение', description: 'Вы переходите к созданию новой диеты на основе скопированной'); if (!context.mounted) return; - GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'diet': diet, 'isEditing': false}); + final result = await GoRouter.of(context).pushNamed( + CreateDietScreen.name, + extra: {'diet': diet, 'isEditing': false}, + ); + if (!context.mounted) return; + if (result == true) { + await context.read().loadAllDiets(); + } } void _deleteDiet(Diet diet, BuildContext context) async { diff --git a/lib/features/recipe/presentation/pages/my_recipes_screen.dart b/lib/features/recipe/presentation/pages/my_recipes_screen.dart index f3fc4b36..36359d0b 100644 --- a/lib/features/recipe/presentation/pages/my_recipes_screen.dart +++ b/lib/features/recipe/presentation/pages/my_recipes_screen.dart @@ -74,8 +74,14 @@ class MyRecipesScreen extends StatelessWidget { ), const SizedBox(width: 8), InkWell( - onTap: () { - context.push(RecipeCreateScreen.path); + onTap: () async { + final result = await context.push(RecipeCreateScreen.path); + if (!context.mounted) return; + if (result == MyRecipesCubit.drafted) { + await context.read().setDraftedState(); + } else if (result == MyRecipesCubit.active) { + await context.read().setActiveState(); + } }, borderRadius: BorderRadius.circular(100), child: Container( diff --git a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart index 31c92082..9d0269de 100644 --- a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart +++ b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart @@ -155,7 +155,13 @@ class _MyRecipeWidgetState extends State { final fullRecipe = await context.read().getRecipeById(widget.recipe.slug); if (context.mounted) { Navigator.pop(context); // close dialog - context.push(RecipeCreateScreen.path, extra: fullRecipe); + final result = await context.push(RecipeCreateScreen.path, extra: fullRecipe); + if (!context.mounted) return; + if (result == MyRecipesCubit.drafted) { + await context.read().setDraftedState(); + } else if (result == MyRecipesCubit.active) { + await context.read().setActiveState(); + } } }, height: 52, @@ -236,10 +242,16 @@ class _MyRecipeWidgetState extends State { } else { showErrorToast('Заполните все необходимые поля для публикации рецепта'); if (context.mounted) { - context.push(RecipeCreateScreen.path, extra: { + final result = await context.push(RecipeCreateScreen.path, extra: { 'recipe': fullRecipe, 'autoValidate': true, }); + if (!context.mounted) return; + if (result == MyRecipesCubit.drafted) { + await context.read().setDraftedState(); + } else if (result == MyRecipesCubit.active) { + await context.read().setActiveState(); + } } } await createCubit.close(); From 2da3b10e6323b3b1d23ab77254be9a2b8c5b7a13 Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Tue, 17 Mar 2026 12:18:17 +0300 Subject: [PATCH 02/25] feat: Implement 'My Recipes' feature, add diary moderation request modal, and introduce diary repository. --- .../glossary_moderation_request_modal_widget.dart | 13 +++++++++---- .../diary/repositories/diary_repository.dart | 6 +++++- .../presentation/pages/my_recipes_screen.dart | 7 +++++-- .../presentation/widgets/my_recipe_widget.dart | 10 ++++++++-- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/features/diary/presentation/widgets/glossary_moderation_request_modal_widget.dart b/lib/features/diary/presentation/widgets/glossary_moderation_request_modal_widget.dart index 286126a6..1c93bb8f 100644 --- a/lib/features/diary/presentation/widgets/glossary_moderation_request_modal_widget.dart +++ b/lib/features/diary/presentation/widgets/glossary_moderation_request_modal_widget.dart @@ -257,12 +257,17 @@ class _GlossaryModerationRequestModalWidgetState extends State().getMyRecipes(); + } }, borderRadius: BorderRadius.circular(100), child: Container( diff --git a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart index 31c92082..d008ee08 100644 --- a/lib/features/recipe/presentation/widgets/my_recipe_widget.dart +++ b/lib/features/recipe/presentation/widgets/my_recipe_widget.dart @@ -155,7 +155,10 @@ class _MyRecipeWidgetState extends State { final fullRecipe = await context.read().getRecipeById(widget.recipe.slug); if (context.mounted) { Navigator.pop(context); // close dialog - context.push(RecipeCreateScreen.path, extra: fullRecipe); + await context.push(RecipeCreateScreen.path, extra: fullRecipe); + if (context.mounted) { + context.read().setDraftedState(); + } } }, height: 52, @@ -236,10 +239,13 @@ class _MyRecipeWidgetState extends State { } else { showErrorToast('Заполните все необходимые поля для публикации рецепта'); if (context.mounted) { - context.push(RecipeCreateScreen.path, extra: { + await context.push(RecipeCreateScreen.path, extra: { 'recipe': fullRecipe, 'autoValidate': true, }); + if (context.mounted) { + context.read().setDraftedState(); + } } } await createCubit.close(); From 3d7755a9a26a521d83a3d0ab67afb7f9a98f78f9 Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Tue, 17 Mar 2026 16:33:54 +0300 Subject: [PATCH 03/25] feat: Introduce `AppRouter` for centralized application routing and deep link handling, and create `push_navigation_handler` for push notification navigation. --- lib/core/app_router.dart | 7 +- .../push_navigation_handler.dart | 77 +++++++++++++++++-- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 4b778cbd..e2322e22 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -15,7 +15,7 @@ class AppRouter { static GlobalKey navigatorKey = GlobalKey(); // Домены для deep links (в т.ч. ссылки из пушей dev2.eatvio.com) - static const List _deepLinkHosts = ['eatvio.ru', 'www.eatvio.ru']; + static const List deepLinkHosts = ['eatvio.ru', 'eatvio.com']; static GoRouter appRouter = GoRouter( initialLocation: RouteConstants.updateScreen, @@ -34,7 +34,8 @@ class AppRouter { final host = uri.host.toLowerCase(); final segments = uri.pathSegments; - final isDeepLink = _deepLinkHosts.contains(host); + // Проверяем, что хост заканчивается на один из разрешенных доменов + final isDeepLink = deepLinkHosts.any((allowedHost) => host == allowedHost || host.endsWith('.$allowedHost')); if (!isDeepLink) { return null; @@ -190,7 +191,7 @@ class AppRouter { if (uri.host.isEmpty && uri.pathSegments.isNotEmpty) { uri = Uri( scheme: 'https', - host: _deepLinkHosts.first, + host: deepLinkHosts.first, pathSegments: uri.pathSegments, queryParameters: uri.queryParameters.isEmpty ? null : uri.queryParameters, ); diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index b26db5e3..085c5b23 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -1,6 +1,7 @@ import 'dart:developer' as developer; import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -70,22 +71,88 @@ class PushNavigationHandler { static void _handleMessageInternal(RemoteMessage message, {required String source}) { final data = message.data; - final actionUrl = data['action_url'] as String?; + + // Пытаемся взять URL из action_url или action (бэкенд шлёт и то и другое) + String? rawUrl = data['action_url'] as String?; + if (rawUrl == null || rawUrl.isEmpty) { + rawUrl = data['action'] as String?; + } - if (actionUrl == null || actionUrl.isEmpty) { + if (rawUrl != null && rawUrl.isNotEmpty) { developer.log( - 'PushNavigationHandler: no action_url in message data, source=$source, data=$data', + 'PushNavigationHandler: navigating by url=$rawUrl, source=$source', name: 'PushNavigationHandler', ); + + // Если это URL, но без схемы, добавляем https + String finalUrl = rawUrl; + if (!finalUrl.startsWith('http') && finalUrl.contains('/')) { + finalUrl = 'https://${AppRouter.deepLinkHosts.first}/$finalUrl'.replaceAll('//', '/').replaceFirst('https:/', 'https://'); + } + + AppRouter.navigateByDeepLinkUrl(finalUrl); + return; + } + + // Fallback: навигация по типу уведомления, если URL отсутствует. + final target = _resolveTargetByType(data); + if (target != null) { + developer.log( + 'PushNavigationHandler: fallback navigation by type, target=$target, source=$source, data=$data', + name: 'PushNavigationHandler', + ); + AppRouter.appRouter.go(target); return; } developer.log( - 'PushNavigationHandler: navigating by action_url=$actionUrl, source=$source', + 'PushNavigationHandler: no action_url/action and no known type, source=$source, data=$data', name: 'PushNavigationHandler', ); + } - AppRouter.navigateByDeepLinkUrl(actionUrl); + /// Определяет маршрут по полям `type` / `action` из data, если URL отсутствует. + static String? _resolveTargetByType(Map data) { + final type = data['type'] as String? ?? ''; + final action = data['action'] as String? ?? ''; + final databaseType = data['database_type'] as String? ?? ''; + + // Чат + if (type == 'chat_message' || databaseType == 'chat-message') { + return RouteConstants.chatSelectScreen; + } + + // Дневник + if (type == 'diary_reminder' || action == 'open_diary' || databaseType == 'diary-reminder') { + return RouteConstants.diaryScreen; + } + + // Комментарий консультанта к дневнику + if (type == 'consultant_diary_comment' || databaseType == 'consultant-left-diary-comment') { + return RouteConstants.diaryScreen; + } + + // Консультации (plan-related) + if (type.startsWith('plan_') || type.startsWith('chosen_plan_') || databaseType.contains('plan')) { + return RouteConstants.consultationsScreen; + } + + // Диета + if (type == 'diet_started' || type == 'diet_cancelled' || databaseType.startsWith('diet-')) { + return RouteConstants.feedScreen; + } + + // Бейдж + if (type == 'badge_awarded' || databaseType == 'badge-awarded') { + return RouteConstants.myProfileScreen; + } + + // Мотивация + if (type == 'motivation') { + return RouteConstants.feedScreen; + } + + return null; } } From 4f241f7db6f8c4fbb92acca289670122088aba68 Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Wed, 18 Mar 2026 15:39:13 +0300 Subject: [PATCH 04/25] feat: try push handling --- android/app/src/main/AndroidManifest.xml | 33 +++---- lib/core/app_router.dart | 86 +++++++++---------- .../push_navigation_handler.dart | 7 +- pubspec.yaml | 2 +- 4 files changed, 56 insertions(+), 72 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e7a94938..3a6b34c3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -55,32 +55,19 @@ + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + +