forked from eatvio/eatvio_app
feat(notifications): added push notifications navigation handling and fixed ws notification parsing
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import 'package:go_router/go_router.dart';
|
||||
class AppRouter {
|
||||
static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
// Домены для deep links
|
||||
static const List<String> _deepLinkHosts = ['eatvio.ru', 'www.eatvio.ru'];
|
||||
// Домены для deep links (в т.ч. ссылки из пушей dev2.eatvio.com)
|
||||
static const List<String> _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}) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
91
lib/core/notifications/push_navigation_handler.dart
Normal file
91
lib/core/notifications/push_navigation_handler.dart
Normal file
@@ -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<void> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic>;
|
||||
@@ -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<String, dynamic>;
|
||||
@@ -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<String, dynamic>?;
|
||||
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<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
return BlogPostScreen(
|
||||
id: data['id'] as int,
|
||||
highlightedCommentId: data['commentId'] as int?,
|
||||
);
|
||||
return BlogPostScreen(id: data['id'] as int, highlightedCommentId: data['commentId'] as int?);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> _connectCubitsToStreams() async {
|
||||
try {
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
} catch (e, st) {
|
||||
log('WsManager._connectCubitsToStreams: $e', stackTrace: st);
|
||||
await Sentry.captureException(e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initPrivateNotificationsChannel() async {
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
_privateNotificationChannel = client?.privateChannel(
|
||||
|
||||
@@ -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<AuthScreen> {
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешный вход');
|
||||
} catch (e) {
|
||||
@@ -213,9 +209,6 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
await sl<WsManager>().initialize();
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешный вход');
|
||||
} catch (e) {
|
||||
|
||||
@@ -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<RegisterViaSocialScreen> {
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешная регистрация');
|
||||
} catch (e) {
|
||||
|
||||
@@ -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<ScanQRCodeScreen> {
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
|
||||
@@ -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<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
|
||||
@@ -146,6 +152,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
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<BottomNavigationBarWidget> {
|
||||
selectedFontSize: 10,
|
||||
unselectedFontSize: 10,
|
||||
selectedItemColor: appTheme.blueA400,
|
||||
currentIndex: currentBottomNavBarIndex,
|
||||
currentIndex: effectiveIndex,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
label: '',
|
||||
@@ -229,7 +245,7 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
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<BottomNavigationBarWidget> {
|
||||
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,
|
||||
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json, NotificationType type, {bool? isRead, String? createdAt}) {
|
||||
final data = json['data'] is Map<String, dynamic> ? json['data'] as Map<String, dynamic> : 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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,
|
||||
|
||||
@@ -349,23 +349,31 @@ class NotificationsCubit extends Cubit<NotificationsState> {
|
||||
void _handleNotification(String notificationData) {
|
||||
final json = jsonDecode(notificationData) as Map<String, dynamic>;
|
||||
|
||||
// Улучшенная логика определения типа уведомления
|
||||
// Улучшенная логика определения типа уведомления (порядок: 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<String, dynamic>) {
|
||||
final data = json['data'] as Map<String, dynamic>;
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> main() async {
|
||||
await AppInitializer.runAppWithSentry(() => runApp(const MyApp()));
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
class MyApp extends StatefulWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
State<MyApp> createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
PushNavigationHandler.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user