From 08eeea6ba4c87d6cfdd388c3c0666e7c556bc262 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 4 Mar 2026 09:05:00 +0300 Subject: [PATCH] feat(notifications): added push notifications navigation handling and fixed ws notification parsing --- .../com/eatvioapp/app/HmsPushService.kt | 43 +++++ .../kotlin/com/eatvioapp/app/MainActivity.kt | 35 +++- lib/core/app_router.dart | 153 ++++++++++++------ lib/core/firebase_api.dart | 1 - .../push_navigation_handler.dart | 91 +++++++++++ lib/core/routing/content_routes.dart | 52 +++--- lib/core/routing/main_navigation_routes.dart | 16 +- lib/core/websockets/ws_manager.dart | 16 ++ .../auth/presentation/pages/auth_screen.dart | 7 - .../pages/register_via_social_screen.dart | 4 - .../pages/scan_qr_code_screen.dart | 4 - .../widgets/bottom_navigation_bar_widget.dart | 26 ++- .../models/notification_model.dart | 122 ++++---------- .../notifications_cubit.dart | 20 ++- .../widgets/notification_widget.dart | 45 ++---- lib/main.dart | 14 +- .../bottom_navigation_bar_widget_test.dart | 10 +- .../widgets/notification_widget_test.dart | 4 +- 18 files changed, 424 insertions(+), 239 deletions(-) create mode 100644 lib/core/notifications/push_navigation_handler.dart diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index f2c7f3d9..f18b3992 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -1,6 +1,10 @@ package com.eatvioapp.app +import android.app.PendingIntent +import android.content.Intent import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import com.huawei.hms.push.HmsMessageService import com.huawei.hms.push.RemoteMessage @@ -13,6 +17,45 @@ class HmsPushService : HmsMessageService() { override fun onMessageReceived(message: RemoteMessage?) { super.onMessageReceived(message) Log.i("HMS", "onMessageReceived: ${message?.data}") + + if (message == null) return + + // Берём action_url из data-поля, если оно есть. + val actionUrl = message.dataOfMap["action_url"] + + // Строим системное уведомление, по тапу по которому откроется MainActivity с action_url. + showNotification(message, actionUrl) + } + + private fun showNotification(message: RemoteMessage, actionUrl: String?) { + val context = applicationContext + + // Интент для запуска Flutter-активити. + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + if (!actionUrl.isNullOrBlank()) { + putExtra("action_url", actionUrl) + } + } + + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val channelId = "hms_push_channel" + + val notification = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения + .setContentTitle(message.notification?.title ?: context.getString(R.string.app_name)) + .setContentText(message.notification?.body ?: "") + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification) } } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index 9b626e4c..97e76a0e 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -1,8 +1,8 @@ package com.eatvioapp.app +import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager -import android.os.Bundle import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -14,12 +14,13 @@ import kotlinx.coroutines.launch class MainActivity: FlutterActivity() { private val channelName = "com.eatvioapp/hms_push" + private var methodChannel: MethodChannel? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) - .setMethodCallHandler { call, result -> + methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) + methodChannel?.setMethodCallHandler { call, result -> when (call.method) { "getHuaweiToken" -> { CoroutineScope(Dispatchers.IO).launch { @@ -71,5 +72,33 @@ class MainActivity: FlutterActivity() { else -> result.notImplemented() } } + + // Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления). + handleIntentForActionUrl(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // Обработка нового интента, когда Activity уже запущена (тап по уведомлению). + handleIntentForActionUrl(intent) + } + + private fun handleIntentForActionUrl(intent: Intent?) { + if (intent == null) return + + // Вариант 1: action_url лежит в extras. + val fromExtras = intent.extras?.getString("action_url") + + // Вариант 2: action_url передан как data Uri. + val dataUri = intent.data + val fromData = dataUri?.toString() + + val actionUrl = fromExtras ?: fromData + if (actionUrl.isNullOrBlank()) return + + android.util.Log.i("HMS", "MainActivity.handleIntentForActionUrl: $actionUrl") + + // Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам. + methodChannel?.invokeMethod("openActionUrl", actionUrl) } } diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 776aea35..c98f7af2 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -14,8 +14,8 @@ import 'package:go_router/go_router.dart'; class AppRouter { static GlobalKey navigatorKey = GlobalKey(); - // Домены для deep links - static const List _deepLinkHosts = ['eatvio.ru', 'www.eatvio.ru']; + // Домены для deep links (в т.ч. ссылки из пушей dev2.eatvio.com) + static const List _deepLinkHosts = ['eatvio.ru', 'www.eatvio.ru', 'dev2.eatvio.com', 'www.dev2.eatvio.com']; static GoRouter appRouter = GoRouter( initialLocation: RouteConstants.updateScreen, @@ -30,64 +30,59 @@ class AppRouter { ], ); - /// Обработка редиректов для deep links - /// Возвращает null для обычной навигации, чтобы не мешать внутренним переходам - static String? _handleRedirect(BuildContext context, GoRouterState state) { - final uri = state.uri; + static String? _resolveDeepLinkUri(Uri uri) { final host = uri.host.toLowerCase(); final segments = uri.pathSegments; - // Проверяем, является ли это deep link по домену final isDeepLink = _deepLinkHosts.contains(host); - - // Если это не deep link, не мешаем обычной навигации + if (!isDeepLink) { return null; } - // Логирование для отладки - developer.log( - 'Deep link detected: host=$host, path=${uri.path}, segments=$segments', - name: 'AppRouter', - ); + developer.log('Deep link detected: host=$host, path=${uri.path}, segments=$segments', name: 'AppRouter'); - // Проверка на пустой путь или некорректный формат if (segments.isEmpty) { developer.log('Empty segments in deep link, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } - // Проверка языка (первый сегмент должен быть ru или en) + if (segments.length >= 4 && + segments[0] == 'api' && + segments[1] == 'v2' && + segments[2] == 'chat' && + segments[3] == 'show') { + developer.log('Deep link: api/v2/chat/show → chat-select-screen', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.chatSelectScreen; + } + if (segments[0] != 'ru' && segments[0] != 'en') { - developer.log( - 'Invalid language segment: ${segments[0]}, redirecting to feed', - name: 'AppRouter', - ); + developer.log('Invalid language segment: ${segments[0]}, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } - // Обработка deep links с учетом языка - // Формат: /{lang}/{type}/{identifier?} if (segments.length == 4) { - // Формат: /{lang}/diary/{...} final type = segments[1]; - + if (type == 'diary') { developer.log('Deep link: diary, redirecting to diary-screen', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.diaryScreen; } - - developer.log( - 'Unknown deep link type for 4 segments: $type, redirecting to feed', - name: 'AppRouter', - ); + if (type == 'consultations' && segments[2] == 'status') { + final consultationId = segments[3]; + developer.log('Deep link: consultations/status/$consultationId', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return '${RouteConstants.consultationsScreen}?id=$consultationId'; + } + + developer.log('Unknown deep link type for 4 segments: $type, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } else if (segments.length == 3) { - // Формат: /{lang}/{type}/{identifier} final type = segments[1]; final contentIdentifier = segments[2]; @@ -104,55 +99,111 @@ class AppRouter { developer.log('Deep link: plan/$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return '/about-plan-screen?slug=$contentIdentifier'; - case 'diet': - developer.log('Deep link: diet/$contentIdentifier', name: 'AppRouter'); + case 'diet': { + final dietId = int.tryParse(contentIdentifier); + if (dietId != null) { + developer.log('Deep link: diet/id=$dietId', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return '/about-diet-screen?id=$dietId'; + } + developer.log('Deep link: diet/slug=$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return '/about-diet-screen?slug=$contentIdentifier'; + } case 'chat': developer.log('Deep link: chat/$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.chatSelectScreen; case 'profile': + // В диплинке передан slug — открываем профиль пользователя по slug. developer.log('Deep link: profile/$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return '/profile-screen?slug=$contentIdentifier'; + case 'badge': + // В badge нет slug, открываем вкладку «Мой профиль». + developer.log('Deep link: badge → my-profile tab', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.myProfileScreen; case 'diary': developer.log('Deep link: diary/$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.diaryScreen; + case 'consultant_diary_comment': + developer.log('Deep link: consultant_diary_comment/$contentIdentifier → diary', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.diaryScreen; default: - developer.log( - 'Unknown deep link type: $type, redirecting to feed', - name: 'AppRouter', - ); + developer.log('Unknown deep link type: $type, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } } else if (segments.length == 2) { - // Формат: /{lang}/{type} - только для chat final type = segments[1]; - + if (type == 'chat') { developer.log('Deep link: chat', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.chatSelectScreen; - } else { - developer.log( - 'Unknown deep link type for 2 segments: $type, redirecting to feed', - name: 'AppRouter', - ); - FlutterNativeSplash.remove(); - return RouteConstants.feedScreen; } - } else { - // Неизвестный формат deep link - developer.log( - 'Unknown deep link format: ${segments.length} segments, redirecting to feed', - name: 'AppRouter', - ); + if (type == 'diary') { + developer.log('Deep link: diary', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.diaryScreen; + } + + developer.log('Unknown deep link type for 2 segments: $type, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } + + developer.log('Unknown deep link format: ${segments.length} segments, redirecting to feed', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.feedScreen; + } + + static String? _handleRedirect(BuildContext context, GoRouterState state) { + return _resolveDeepLinkUri(state.uri); + } + + static void navigateByDeepLinkUrl(String url) { + if (url.isEmpty) { + developer.log('navigateByDeepLinkUrl: empty url, redirecting to feed', name: 'AppRouter'); + appRouter.go(RouteConstants.feedScreen); + return; + } + + Uri? uri; + try { + uri = Uri.tryParse(url); + } catch (e) { + developer.log('navigateByDeepLinkUrl: failed to parse url=$url, error=$e, redirecting to feed', name: 'AppRouter'); + appRouter.go(RouteConstants.feedScreen); + return; + } + + if (uri == null) { + developer.log('navigateByDeepLinkUrl: uri is null for url=$url, redirecting to feed', name: 'AppRouter'); + appRouter.go(RouteConstants.feedScreen); + return; + } + + if (uri.host.isEmpty && uri.pathSegments.isNotEmpty) { + uri = Uri( + scheme: 'https', + host: _deepLinkHosts.first, + pathSegments: uri.pathSegments, + queryParameters: uri.queryParameters.isEmpty ? null : uri.queryParameters, + ); + } + + final target = _resolveDeepLinkUri(uri); + + if (target != null) { + appRouter.go(target); + } else { + developer.log('navigateByDeepLinkUrl: unresolved url=$url (normalized=${uri.toString()}), redirecting to feed', name: 'AppRouter'); + appRouter.go(RouteConstants.feedScreen); + } } static void navigateToRegisterViaSocial({required AuthResultEntity authResult, required String provider}) { diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index e85807ee..1d09d7a3 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -5,7 +5,6 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:flutter/services.dart'; - import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/locator_service.dart'; diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart new file mode 100644 index 00000000..b26db5e3 --- /dev/null +++ b/lib/core/notifications/push_navigation_handler.dart @@ -0,0 +1,91 @@ +import 'dart:developer' as developer; + +import 'package:eatvio_app/core/app_router.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Отвечает за навигацию по диплинкам, пришедшим в пуш-уведомлениях. +class PushNavigationHandler { + static bool _initialized = false; + static const MethodChannel _hmsChannel = MethodChannel('com.eatvioapp/hms_push'); + + /// Инициализация обработчиков Firebase Messaging. + /// Должна вызываться один раз после старта приложения. + static Future initialize() async { + if (_initialized) return; + _initialized = true; + + // Сообщение, по которому запустили приложение из "убитого" состояния. + try { + final initialMessage = await FirebaseMessaging.instance.getInitialMessage(); + if (initialMessage != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleMessageInternal(initialMessage, source: 'initialMessage'); + }); + } + } catch (e) { + developer.log( + 'PushNavigationHandler: error while getting initialMessage: $e', + name: 'PushNavigationHandler', + ); + } + + // Тап по уведомлению, когда приложение было в фоне. + FirebaseMessaging.onMessageOpenedApp.listen( + (message) => _handleMessageInternal(message, source: 'onMessageOpenedApp'), + ); + + // Подписка на события от native-кода (HMS через MethodChannel). + _hmsChannel.setMethodCallHandler((call) async { + if (call.method == 'openActionUrl') { + final url = call.arguments as String? ?? ''; + handleActionUrlFromNative(url); + } + }); + } + + /// Входная точка для навигации из native-кода (например, HMS через MethodChannel). + static void handleActionUrlFromNative(String url) { + if (url.isEmpty) { + developer.log( + 'PushNavigationHandler.handleActionUrlFromNative: empty url', + name: 'PushNavigationHandler', + ); + return; + } + + developer.log( + 'PushNavigationHandler.handleActionUrlFromNative: navigating by url=$url', + name: 'PushNavigationHandler', + ); + + AppRouter.navigateByDeepLinkUrl(url); + } + + /// Публичный метод для единичной обработки сообщения (если понадобится где-то ещё). + static void handleMessage(RemoteMessage message) { + _handleMessageInternal(message, source: 'manualHandle'); + } + + static void _handleMessageInternal(RemoteMessage message, {required String source}) { + final data = message.data; + final actionUrl = data['action_url'] as String?; + + if (actionUrl == null || actionUrl.isEmpty) { + developer.log( + 'PushNavigationHandler: no action_url in message data, source=$source, data=$data', + name: 'PushNavigationHandler', + ); + return; + } + + developer.log( + 'PushNavigationHandler: navigating by action_url=$actionUrl, source=$source', + name: 'PushNavigationHandler', + ); + + AppRouter.navigateByDeepLinkUrl(actionUrl); + } +} + diff --git a/lib/core/routing/content_routes.dart b/lib/core/routing/content_routes.dart index 5a93013c..dcb3d182 100644 --- a/lib/core/routing/content_routes.dart +++ b/lib/core/routing/content_routes.dart @@ -21,12 +21,7 @@ class ContentRoutes { final commentId = int.tryParse(commentIdStr ?? ''); if (slug != null) { - return AboutPlanScreen( - slug: slug, - navigateToComments: commentId != null, - index: null, - highlightedCommentId: commentId, - ); + return AboutPlanScreen(slug: slug, navigateToComments: commentId != null, index: null, highlightedCommentId: commentId); } final data = state.extra as Map; @@ -47,12 +42,7 @@ class ContentRoutes { final commentId = int.tryParse(commentIdStr ?? ''); if (slug != null) { - return AboutRecipeScreen( - recipeSlug: slug, - navigateToComments: commentId != null, - index: null, - highlightedCommentId: commentId, - ); + return AboutRecipeScreen(recipeSlug: slug, navigateToComments: commentId != null, index: null, highlightedCommentId: commentId); } final data = state.extra as Map; @@ -69,26 +59,28 @@ class ContentRoutes { name: 'aboutDietScreen', builder: (context, state) { final slug = state.uri.queryParameters['slug']; + final idStr = state.uri.queryParameters['id']; + final id = int.tryParse(idStr ?? ''); final commentIdStr = state.uri.queryParameters['commentId']; final commentId = int.tryParse(commentIdStr ?? ''); if (slug != null) { + return AboutDietScreen(id: null, slug: slug, index: null, navigateToComments: commentId != null, highlightedCommentId: commentId); + } + if (id != null && id > 0) { + return AboutDietScreen(id: id, slug: null, index: null, navigateToComments: commentId != null, highlightedCommentId: commentId); + } + final data = state.extra as Map?; + if (data != null) { return AboutDietScreen( - id: null, - slug: slug, - index: null, - navigateToComments: commentId != null, - highlightedCommentId: commentId, + id: data['id'] as int?, + slug: data['slug'] as String?, + index: data['index'] as int?, + navigateToComments: data['navigateToComments'] as bool? ?? (data['commentId'] != null), + highlightedCommentId: data['commentId'] as int?, ); } - final data = state.extra as Map; - return AboutDietScreen( - id: data['id'] as int?, - slug: data['slug'] as String?, - index: data['index'] as int?, - navigateToComments: data['navigateToComments'] as bool? ?? (data['commentId'] != null), - highlightedCommentId: data['commentId'] as int?, - ); + return const AboutDietScreen(id: null, slug: null, index: null, navigateToComments: false, highlightedCommentId: null); }, ), GoRoute( @@ -106,10 +98,7 @@ class ContentRoutes { final parsedId = int.tryParse(id ?? ''); if (parsedId != null) { - return BlogPostScreen( - id: parsedId, - highlightedCommentId: commentId, - ); + return BlogPostScreen(id: parsedId, highlightedCommentId: commentId); } if (state.extra is int) { @@ -117,10 +106,7 @@ class ContentRoutes { } final data = state.extra as Map; - return BlogPostScreen( - id: data['id'] as int, - highlightedCommentId: data['commentId'] as int?, - ); + return BlogPostScreen(id: data['id'] as int, highlightedCommentId: data['commentId'] as int?); }, ), ]; diff --git a/lib/core/routing/main_navigation_routes.dart b/lib/core/routing/main_navigation_routes.dart index f7756fbd..485ff24d 100644 --- a/lib/core/routing/main_navigation_routes.dart +++ b/lib/core/routing/main_navigation_routes.dart @@ -33,7 +33,10 @@ class MainNavigationRoutes { }); } - return _MainShellWithNavScope(child: child); + return _MainShellWithNavScope( + child: child, + currentLocation: state.matchedLocation, + ); }, routes: [ GoRoute( @@ -102,9 +105,13 @@ class MainNavigationRoutes { /// Wraps the shell with [BottomNavScope] so descendants (e.g. notifications) /// can switch tabs via [BottomNavController.switchToRoute] instead of pushing. class _MainShellWithNavScope extends StatefulWidget { - const _MainShellWithNavScope({required this.child}); + const _MainShellWithNavScope({ + required this.child, + required this.currentLocation, + }); final Widget child; + final String currentLocation; @override State<_MainShellWithNavScope> createState() => _MainShellWithNavScopeState(); @@ -117,7 +124,10 @@ class _MainShellWithNavScopeState extends State<_MainShellWithNavScope> { Widget build(BuildContext context) { return BottomNavScope( controller: _navController, - child: BottomNavigationBarWidget(child: widget.child), + child: BottomNavigationBarWidget( + child: widget.child, + currentLocation: widget.currentLocation, + ), ); } } diff --git a/lib/core/websockets/ws_manager.dart b/lib/core/websockets/ws_manager.dart index 3a711177..cf84354c 100644 --- a/lib/core/websockets/ws_manager.dart +++ b/lib/core/websockets/ws_manager.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/core/websockets/ws_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; +import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; class WsManager { final WsService wsService; @@ -111,6 +112,7 @@ class WsManager { } }); _subscribe(); + await _connectCubitsToStreams(); } catch (e, st) { await Sentry.captureException(e, stackTrace: st); log(e.toString(), stackTrace: st); @@ -118,6 +120,20 @@ class WsManager { } } + /// Подключает ChatsCubit и NotificationsCubit к стримам сокета. + /// Вызывается после успешного initialize(), чтобы уведомления и чаты работали + /// и при входе уже залогиненного пользователя (редирект на feed), не только после экрана логина. + Future _connectCubitsToStreams() async { + try { + await sl().connectToWebSocket(); + await sl().getNotifications(); + await sl().connectToWebSocket(); + } catch (e, st) { + log('WsManager._connectCubitsToStreams: $e', stackTrace: st); + await Sentry.captureException(e, stackTrace: st); + } + } + Future _initPrivateNotificationsChannel() async { final token = await TokenStorage.getTokenFromStorage(); _privateNotificationChannel = client?.privateChannel( diff --git a/lib/features/auth/presentation/pages/auth_screen.dart b/lib/features/auth/presentation/pages/auth_screen.dart index bf9074d9..d1c1ccfd 100644 --- a/lib/features/auth/presentation/pages/auth_screen.dart +++ b/lib/features/auth/presentation/pages/auth_screen.dart @@ -21,7 +21,6 @@ import 'package:eatvio_app/core/di/cubit_registrations.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; -import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; @@ -80,9 +79,6 @@ class _AuthScreenState extends State { try { await sl().getMyProfile(); await sl().getChatRoomsWithLoading(); - await sl().connectToWebSocket(); - await sl().getNotifications(); - await sl().connectToWebSocket(); await sl().registerPushToken(); showSuccessToast('Успешный вход'); } catch (e) { @@ -213,9 +209,6 @@ class _AuthScreenState extends State { await sl().initialize(); await sl().getMyProfile(); await sl().getChatRoomsWithLoading(); - await sl().connectToWebSocket(); - await sl().getNotifications(); - await sl().connectToWebSocket(); await sl().registerPushToken(); showSuccessToast('Успешный вход'); } catch (e) { diff --git a/lib/features/auth/presentation/pages/register_via_social_screen.dart b/lib/features/auth/presentation/pages/register_via_social_screen.dart index 1b27237f..1ea307f0 100644 --- a/lib/features/auth/presentation/pages/register_via_social_screen.dart +++ b/lib/features/auth/presentation/pages/register_via_social_screen.dart @@ -13,7 +13,6 @@ import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; -import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/utils/validation_utils.dart'; @@ -74,9 +73,6 @@ class _RegisterViaSocialScreenState extends State { try { await sl().getMyProfile(); await sl().getChatRoomsWithLoading(); - await sl().connectToWebSocket(); - await sl().getNotifications(); - await sl().connectToWebSocket(); await sl().registerPushToken(); showSuccessToast('Успешная регистрация'); } catch (e) { diff --git a/lib/features/auth/presentation/pages/scan_qr_code_screen.dart b/lib/features/auth/presentation/pages/scan_qr_code_screen.dart index 723f95e9..4b90fe70 100644 --- a/lib/features/auth/presentation/pages/scan_qr_code_screen.dart +++ b/lib/features/auth/presentation/pages/scan_qr_code_screen.dart @@ -10,7 +10,6 @@ import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; -import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart'; @@ -76,9 +75,6 @@ class _ScanQRCodeScreenState extends State { try { await sl().getMyProfile(); await sl().getChatRoomsWithLoading(); - await sl().connectToWebSocket(); - await sl().getNotifications(); - await sl().connectToWebSocket(); await sl().registerPushToken(); } catch (e) { rethrow; diff --git a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart index cd534644..207b348a 100644 --- a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart +++ b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart @@ -19,9 +19,15 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; class BottomNavigationBarWidget extends StatefulWidget { - final Widget child; + const BottomNavigationBarWidget({ + super.key, + required this.child, + required this.currentLocation, + }); - const BottomNavigationBarWidget({super.key, required this.child}); + final Widget child; + /// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши). + final String currentLocation; @override State createState() => _BottomNavigationBarWidgetState(); @@ -146,6 +152,16 @@ class _BottomNavigationBarWidgetState extends State { controller.register(_switchToRoute); } + // Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши). + final pathWithoutQuery = widget.currentLocation.split('?').first; + final indexFromRoute = pages!.indexOf(pathWithoutQuery); + final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex; + if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute); + }); + } + return Scaffold( body: widget.child, bottomNavigationBar: BottomNavigationBar( @@ -154,7 +170,7 @@ class _BottomNavigationBarWidgetState extends State { selectedFontSize: 10, unselectedFontSize: 10, selectedItemColor: appTheme.blueA400, - currentIndex: currentBottomNavBarIndex, + currentIndex: effectiveIndex, items: [ BottomNavigationBarItem( label: '', @@ -229,7 +245,7 @@ class _BottomNavigationBarWidgetState extends State { ImageConstant.icnChat, width: 20, height: 20, - colorFilter: pages![currentBottomNavBarIndex] == '/chat-select-screen' + colorFilter: pages![effectiveIndex] == '/chat-select-screen' ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) : const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ), @@ -251,7 +267,7 @@ class _BottomNavigationBarWidgetState extends State { padding: const EdgeInsets.only(bottom: 2), child: SvgPicture.asset( ImageConstant.icnBell, - colorFilter: pages![currentBottomNavBarIndex] == '/notifications-screen' + colorFilter: pages![effectiveIndex] == '/notifications-screen' ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) : const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, diff --git a/lib/features/notifications/models/notification_model.dart b/lib/features/notifications/models/notification_model.dart index 5715fb90..b178eb91 100644 --- a/lib/features/notifications/models/notification_model.dart +++ b/lib/features/notifications/models/notification_model.dart @@ -166,13 +166,7 @@ class DiaryReminderNotification implements BaseNotificationModel { @override final DateTime createdAt; - DiaryReminderNotification({ - required this.type, - required this.isRead, - this.diaryId, - this.message, - required this.createdAt, - }); + DiaryReminderNotification({required this.type, required this.isRead, this.diaryId, this.message, required this.createdAt}); factory DiaryReminderNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return DiaryReminderNotification( @@ -581,6 +575,22 @@ class ComplaintToModerationForUser implements BaseNotificationModel { } } +enum CommentedEntityType { + diet, + blogPost, + unknown; + + factory CommentedEntityType.fromJson(String type) { + return switch (type) { + 'diet' => CommentedEntityType.diet, + 'Modules\\UserGeneratedPublicContent\\Models\\Diet' => CommentedEntityType.diet, + 'blog-post' => CommentedEntityType.blogPost, + 'Modules\\UserGeneratedPublicContent\\Models\\BlogPost' => CommentedEntityType.blogPost, + _ => CommentedEntityType.unknown, + }; + } +} + class CommentReplied implements BaseNotificationModel { @override final NotificationType type; @@ -588,7 +598,7 @@ class CommentReplied implements BaseNotificationModel { final bool isRead; final String entityName; final int commentedEntityId; - final String commentedEntityType; + final CommentedEntityType commentedEntityType; final String author; final int authorId; final int parentCommentId; @@ -614,7 +624,7 @@ class CommentReplied implements BaseNotificationModel { type: type, entityName: json['entity_name'], commentedEntityId: json['commented_entity_id'], - commentedEntityType: json['commented_entity_type'], + commentedEntityType: CommentedEntityType.fromJson(json['commented_entity_type']), author: json['author'], authorId: json['author_id'], parentCommentId: json['parrent_comment_id'], @@ -673,7 +683,7 @@ class EntityCommented implements BaseNotificationModel { final bool isRead; final String entityName; final int entityId; - final String entityType; + final CommentedEntityType entityType; final String entityTranslatedType; final String entityUrl; final String authorName; @@ -703,7 +713,7 @@ class EntityCommented implements BaseNotificationModel { type: type, entityName: json['entity_name'], entityId: json['entity_id'], - entityType: json['entity_type'], + entityType: CommentedEntityType.fromJson(json['entity_type']), entityTranslatedType: json['entity_traslated_type'], entityUrl: json['entity_url'], authorName: json['author_name'], @@ -1012,11 +1022,7 @@ class UnknownNotification implements BaseNotificationModel { UnknownNotification({required this.type, this.isRead = true, required this.createdAt}); factory UnknownNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { - return UnknownNotification( - type: type, - isRead: isRead ?? false, - createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), - ); + return UnknownNotification(type: type, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String())); } } @@ -1889,12 +1895,7 @@ class ClientCloseComplaintSuccessNotification implements BaseNotificationModel { @override final DateTime createdAt; - ClientCloseComplaintSuccessNotification({ - required this.type, - required this.planName, - required this.isRead, - required this.createdAt, - }); + ClientCloseComplaintSuccessNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); factory ClientCloseComplaintSuccessNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ClientCloseComplaintSuccessNotification( @@ -2001,13 +2002,7 @@ class ConsultantRejectSuccessNotification implements BaseNotificationModel { @override final DateTime createdAt; - ConsultantRejectSuccessNotification({ - required this.type, - required this.planName, - required this.clientName, - required this.isRead, - required this.createdAt, - }); + ConsultantRejectSuccessNotification({required this.type, required this.planName, required this.clientName, required this.isRead, required this.createdAt}); factory ConsultantRejectSuccessNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ConsultantRejectSuccessNotification( @@ -2115,13 +2110,7 @@ class DiaryReminderForUsersNotification implements BaseNotificationModel { @override final DateTime createdAt; - DiaryReminderForUsersNotification({ - required this.type, - required this.isRead, - this.diaryId, - this.message, - required this.createdAt, - }); + DiaryReminderForUsersNotification({required this.type, required this.isRead, this.diaryId, this.message, required this.createdAt}); factory DiaryReminderForUsersNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { final data = json['data'] is Map ? json['data'] as Map : json; @@ -2196,12 +2185,7 @@ class ConsultantEducationUpdatedNotification implements BaseNotificationModel { final DateTime createdAt; final String consultantName; - ConsultantEducationUpdatedNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.consultantName, - }); + ConsultantEducationUpdatedNotification({required this.type, required this.isRead, required this.createdAt, required this.consultantName}); factory ConsultantEducationUpdatedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ConsultantEducationUpdatedNotification( @@ -2258,13 +2242,7 @@ class ContentUnpublishedNotification implements BaseNotificationModel { final String reason; final String entityName; - ContentUnpublishedNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.reason, - required this.entityName, - }); + ContentUnpublishedNotification({required this.type, required this.isRead, required this.createdAt, required this.reason, required this.entityName}); factory ContentUnpublishedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ContentUnpublishedNotification( @@ -2286,12 +2264,7 @@ class FinanceQuestionCreatedNotification implements BaseNotificationModel { final DateTime createdAt; final String questionText; - FinanceQuestionCreatedNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.questionText, - }); + FinanceQuestionCreatedNotification({required this.type, required this.isRead, required this.createdAt, required this.questionText}); factory FinanceQuestionCreatedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return FinanceQuestionCreatedNotification( @@ -2343,11 +2316,7 @@ class CompleteRegistrationNotification implements BaseNotificationModel { @override final DateTime createdAt; - CompleteRegistrationNotification({ - required this.type, - required this.isRead, - required this.createdAt, - }); + CompleteRegistrationNotification({required this.type, required this.isRead, required this.createdAt}); factory CompleteRegistrationNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return CompleteRegistrationNotification( @@ -2368,13 +2337,7 @@ class ChosenDietExpiredNotification implements BaseNotificationModel { final String dietName; final int dietId; - ChosenDietExpiredNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.dietName, - required this.dietId, - }); + ChosenDietExpiredNotification({required this.type, required this.isRead, required this.createdAt, required this.dietName, required this.dietId}); factory ChosenDietExpiredNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenDietExpiredNotification( @@ -2396,12 +2359,7 @@ class MotivateUserNotification implements BaseNotificationModel { final DateTime createdAt; final String message; - MotivateUserNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.message, - }); + MotivateUserNotification({required this.type, required this.isRead, required this.createdAt, required this.message}); factory MotivateUserNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return MotivateUserNotification( @@ -2423,13 +2381,7 @@ class CommentComplaintNotification implements BaseNotificationModel { final String commentPreview; final String reason; - CommentComplaintNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.commentPreview, - required this.reason, - }); + CommentComplaintNotification({required this.type, required this.isRead, required this.createdAt, required this.commentPreview, required this.reason}); factory CommentComplaintNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return CommentComplaintNotification( @@ -2451,12 +2403,7 @@ class CommentDeletedNotification implements BaseNotificationModel { final DateTime createdAt; final String commentPreview; - CommentDeletedNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.commentPreview, - }); + CommentDeletedNotification({required this.type, required this.isRead, required this.createdAt, required this.commentPreview}); factory CommentDeletedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return CommentDeletedNotification( @@ -2655,7 +2602,7 @@ enum NotificationType { 'stay-on-track-to-customer' => NotificationType.stayOnTrackToCustomer, 'diary-reminder-for-users' => NotificationType.diaryReminderForUsers, 'personal-diet-is-defined-to-customer' => NotificationType.personalDietIsDefinedToCustomer, - + // New additions 'consultant-education-updated' => NotificationType.consultantEducationUpdated, 'complaint-registered' => NotificationType.complaintRegistered, @@ -2671,7 +2618,6 @@ enum NotificationType { 'user-subscribed' => NotificationType.userSubscribed, // 'diary-reminder' => NotificationType.diaryReminder, // Already legacy? Check if it maps to diaryReminderNotification 'meal-reminder' => NotificationType.mealReminder, // Maps to legacy if string matches? No, legacy was App\\... - // Legacy mapping 'App\\Notifications\\Plan\\PlanSubscriptionStartedToAuthor' => NotificationType.planSubscriptionStartedToAuthor, 'App\\Notifications\\Plan\\PlanSubscriptionCanceledToAuthor' => NotificationType.planSubscriptionCanceledToAuthor, diff --git a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart index 54c84507..51771c24 100644 --- a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart +++ b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart @@ -349,23 +349,31 @@ class NotificationsCubit extends Cubit { void _handleNotification(String notificationData) { final json = jsonDecode(notificationData) as Map; - // Улучшенная логика определения типа уведомления + // Улучшенная логика определения типа уведомления (порядок: database_type → type → notification_class_name) String? stringifiedType; - // Сначала проверяем поле 'type' в корне JSON - if (json.containsKey('type') && json['type'] != null) { + // Сначала проверяем поле 'database_type' в корне JSON + if (json.containsKey('database_type') && json['database_type'] != null) { + stringifiedType = json['database_type'].toString(); + log('Found database_type in root: $stringifiedType'); + } + // Затем поле 'type' в корне + else if (json.containsKey('type') && json['type'] != null) { stringifiedType = json['type'].toString(); log('Found type in root: $stringifiedType'); } - // Затем проверяем поле 'notification_class_name' в корне JSON + // Затем поле 'notification_class_name' в корне else if (json.containsKey('notification_class_name') && json['notification_class_name'] != null) { stringifiedType = json['notification_class_name'].toString(); log('Found notification_class_name in root: $stringifiedType'); } - // Если есть поле 'data', проверяем внутри него + // Если есть поле 'data', проверяем внутри него в том же порядке else if (json.containsKey('data') && json['data'] is Map) { final data = json['data'] as Map; - if (data.containsKey('type') && data['type'] != null) { + if (data.containsKey('database_type') && data['database_type'] != null) { + stringifiedType = data['database_type'].toString(); + log('Found database_type in data: $stringifiedType'); + } else if (data.containsKey('type') && data['type'] != null) { stringifiedType = data['type'].toString(); log('Found type in data: $stringifiedType'); } else if (data.containsKey('notification_class_name') && data['notification_class_name'] != null) { diff --git a/lib/features/notifications/presentation/widgets/notification_widget.dart b/lib/features/notifications/presentation/widgets/notification_widget.dart index f33b9cb1..cdde4bbf 100644 --- a/lib/features/notifications/presentation/widgets/notification_widget.dart +++ b/lib/features/notifications/presentation/widgets/notification_widget.dart @@ -147,17 +147,16 @@ void _navigateToBottomNavRoute(BuildContext context, String path, [Object? extra } } -void _navigateToEntity(BuildContext context, String type, int id, int? commentId) { - if (type.contains('BlogPost')) { - context.push(RouteConstants.blogPostScreen, extra: {'id': id, 'highlightedCommentId': commentId}); - } else if (type.contains('Recipe')) { - // TODO: aboutRecipesScreen требует recipe slug, в уведомлении только id — навигация отключена - } else if (type.contains('Plan')) { - // TODO: aboutPlanScreen требует slug плана, в уведомлении только id — навигация отключена - } else if (type.contains('Diet')) { - if (id > 0) { - context.push(RouteConstants.aboutDietScreen, extra: {'id': id, 'highlightedCommentId': commentId}); - } +void _navigateToEntity(BuildContext context, CommentedEntityType type, int id, int? commentId) { + switch (type) { + case CommentedEntityType.blogPost: + context.push(RouteConstants.blogPostScreen, extra: {'id': id, 'highlightedCommentId': commentId}); + case CommentedEntityType.diet: + if (id > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': id, 'highlightedCommentId': commentId}); + } + case CommentedEntityType.unknown: + break; } } @@ -184,7 +183,6 @@ class _PlanSubscriptionCanceledToAuthorWidget extends StatelessWidget { final PlanSubscriptionCanceledToAuthorNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Отмена подписки. Клиент ${notification.customerName} отказался от плана "${notification.planName}"', @@ -201,7 +199,6 @@ class _ChosenPlanStartedToClientWidget extends StatelessWidget { final ChosenPlanStartedToClientNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Ваш план "${notification.planName}" начался! Удачи! 🚀', @@ -218,7 +215,6 @@ class _ChosenPlanTomorrowReminderToClientWidget extends StatelessWidget { final ChosenPlanTomorrowReminderToClientNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Напоминание: Завтра старт плана "${notification.planName}"', @@ -250,7 +246,6 @@ class _ConsultantLeftCommentWidget extends StatelessWidget { final ConsultantLeftCommentNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Новый комментарий от консультанта (за ${notification.date.format('dd.MM.yyyy')})', @@ -283,7 +278,6 @@ class _ChosenPlanFinishedToClientWidget extends StatelessWidget { final ChosenPlanFinishedToClientNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Поздравляем! План «${notification.planName}» успешно завершен! 🏆', @@ -297,7 +291,6 @@ class _PlanSubscriptionCanceledToCustomerWidget extends StatelessWidget { final PlanSubscriptionCanceledToCustomerNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Подписка на план "${notification.planName}" отменена.', @@ -311,7 +304,6 @@ class _ChosenPlanStartedToConsultantWidget extends StatelessWidget { final ChosenPlanStartedToConsultantNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Начало работы: План "${notification.planName}" с клиентом ${notification.customerName}', @@ -325,7 +317,6 @@ class _ChosenPlanTomorrowReminderToConsultantWidget extends StatelessWidget { final ChosenPlanTomorrowReminderToConsultantNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Напоминание: Завтра старт работы с ${notification.customerName} по плану "${notification.planName}"', @@ -339,7 +330,6 @@ class _PlanSubscriptionPaidToAuthorWidget extends StatelessWidget { final PlanSubscriptionPaidToAuthorNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Оплата получена! Клиент ${notification.customerName} оплатил план "${notification.planName}" 💰', @@ -548,11 +538,6 @@ class _ComplaintToModerationForUserWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Ваша жалоба принята и находится на модерации.', createdAt: notification.createdAt, - onTap: (['Plan', 'Recipe'].contains(notification.entityType) || notification.entityId <= 0 || notification.entityType.isEmpty) - ? null - : () { - _navigateToEntity(context, notification.entityType, notification.entityId, null); - }, ); } } @@ -566,7 +551,7 @@ class _CommentRepliedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Вам ответили в комментариях к: ${notification.entityName}', createdAt: notification.createdAt, - onTap: (['Plan', 'Recipe'].contains(notification.commentedEntityType) || notification.commentedEntityId <= 0 || notification.commentId == null) + onTap: (notification.commentedEntityType == CommentedEntityType.unknown || notification.commentedEntityId <= 0) ? null : () { _navigateToEntity(context, notification.commentedEntityType, notification.commentedEntityId, notification.commentId); @@ -600,9 +585,11 @@ class _EntityCommentedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Новый комментарий к вашей публикации "${notification.entityTranslatedType}": ${notification.entityName}', createdAt: notification.createdAt, - onTap: () { - _navigateToEntity(context, notification.entityType, notification.entityId, notification.commentId); - }, + onTap: (notification.entityType == CommentedEntityType.unknown || notification.entityId <= 0) + ? null + : () { + _navigateToEntity(context, notification.entityType, notification.entityId, notification.commentId); + }, ); } } diff --git a/lib/main.dart b/lib/main.dart index af6db638..6513e685 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/notifications/push_navigation_handler.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_portal/flutter_portal.dart'; @@ -13,9 +14,20 @@ Future main() async { await AppInitializer.runAppWithSentry(() => runApp(const MyApp())); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + void initState() { + super.initState(); + PushNavigationHandler.initialize(); + } + @override Widget build(BuildContext context) { return MultiBlocProvider( diff --git a/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart b/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart index 721af4b3..070a4c25 100644 --- a/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart +++ b/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart @@ -109,7 +109,10 @@ void main() { ], child: wrap( const Scaffold( - body: BottomNavigationBarWidget(child: Text('Test Content')), + body: BottomNavigationBarWidget( + child: Text('Test Content'), + currentLocation: '/feed-screen', + ), ), ), ), @@ -145,7 +148,10 @@ void main() { ], child: wrap( const Scaffold( - body: BottomNavigationBarWidget(child: Text('Test Content')), + body: BottomNavigationBarWidget( + child: Text('Test Content'), + currentLocation: '/feed-screen', + ), ), ), ), diff --git a/test/features/notifications/presentation/widgets/notification_widget_test.dart b/test/features/notifications/presentation/widgets/notification_widget_test.dart index db0ccb71..d8f56164 100644 --- a/test/features/notifications/presentation/widgets/notification_widget_test.dart +++ b/test/features/notifications/presentation/widgets/notification_widget_test.dart @@ -311,7 +311,7 @@ void main() { type: NotificationType.commentReplied, entityName: 'Test Entity', commentedEntityId: 1, - commentedEntityType: 'test', + commentedEntityType: CommentedEntityType.diet, author: 'Test Author', authorId: 1, parentCommentId: 1, @@ -349,7 +349,7 @@ void main() { type: NotificationType.entityCommented, entityName: 'Test Entity', entityId: 1, - entityType: 'test', + entityType: CommentedEntityType.diet, entityTranslatedType: 'Test Type', entityUrl: 'test_url', authorName: 'Test Author',