Merge pull request 'feat(notifications)' (#921) from misha/eatvio_app:issue-911 into main

Reviewed-on: eatvio/eatvio_app#921
This commit is contained in:
2026-03-04 07:55:58 +00:00
42 changed files with 2384 additions and 450 deletions

View File

@@ -9,7 +9,7 @@ env:
jobs:
build:
runs-on: [ dev-office ]
runs-on: [ ubuntu-latest ]
steps:
- uses: https://github.com/actions/checkout@v3

1
.gitignore vendored
View File

@@ -21,6 +21,7 @@ env/.env
android/key.properties
app/upload-keystore.jks
coverage/
backend/
.fvm/
.fvmrc
/eatvio

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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}) {

View File

@@ -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';

View 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);
}
}

View File

@@ -17,12 +17,20 @@ class ContentRoutes {
name: 'aboutPlanScreen',
builder: (context, state) {
final slug = state.uri.queryParameters['slug'];
final commentIdStr = state.uri.queryParameters['commentId'];
final commentId = int.tryParse(commentIdStr ?? '');
if (slug != null) {
return AboutPlanScreen(slug: slug, navigateToComments: false, index: null);
return AboutPlanScreen(slug: slug, navigateToComments: commentId != null, index: null, highlightedCommentId: commentId);
}
final data = state.extra as Map<String, dynamic>;
return AboutPlanScreen(slug: data['slug'] as String, index: data['index'] as int?, navigateToComments: data['navigateToComments'] as bool);
return AboutPlanScreen(
slug: data['slug'] as String,
index: data['index'] as int?,
navigateToComments: data['navigateToComments'] as bool? ?? (data['commentId'] != null),
highlightedCommentId: data['commentId'] as int?,
);
},
),
GoRoute(
@@ -30,12 +38,20 @@ class ContentRoutes {
name: 'aboutRecipesScreen',
builder: (context, state) {
final slug = state.uri.queryParameters['slug'];
final commentIdStr = state.uri.queryParameters['commentId'];
final commentId = int.tryParse(commentIdStr ?? '');
if (slug != null) {
return AboutRecipeScreen(recipeSlug: slug, navigateToComments: false, index: null);
return AboutRecipeScreen(recipeSlug: slug, navigateToComments: commentId != null, index: null, highlightedCommentId: commentId);
}
final data = state.extra as Map<String, dynamic>;
return AboutRecipeScreen(recipeSlug: data['recipe'] as String, index: data['index'] as int?, navigateToComments: data['navigateToComments'] as bool);
return AboutRecipeScreen(
recipeSlug: data['recipe'] as String,
index: data['index'] as int?,
navigateToComments: data['navigateToComments'] as bool? ?? (data['commentId'] != null),
highlightedCommentId: data['commentId'] as int?,
);
},
),
GoRoute(
@@ -43,16 +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: false);
return AboutDietScreen(id: null, slug: slug, index: null, navigateToComments: commentId != null, highlightedCommentId: commentId);
}
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,
);
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: 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(
@@ -65,12 +93,20 @@ class ContentRoutes {
name: 'blogPostScreen',
builder: (context, state) {
final id = state.uri.queryParameters['id'];
final commentIdStr = state.uri.queryParameters['commentId'];
final commentId = int.tryParse(commentIdStr ?? '');
final parsedId = int.tryParse(id ?? '');
if (parsedId != null) {
return BlogPostScreen(id: parsedId);
return BlogPostScreen(id: parsedId, highlightedCommentId: commentId);
}
return BlogPostScreen(id: state.extra as int);
if (state.extra is int) {
return BlogPostScreen(id: state.extra as int);
}
final data = state.extra as Map<String, dynamic>;
return BlogPostScreen(id: data['id'] as int, highlightedCommentId: data['commentId'] as int?);
},
),
];

View File

@@ -1,6 +1,7 @@
import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart';
import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart';
import 'package:eatvio_app/features/consultations/presentation/pages/consultations_screen.dart';
@@ -31,8 +32,11 @@ class MainNavigationRoutes {
profileAvatarCubit.getMyProfile();
});
}
return BottomNavigationBarWidget(child: child);
return _MainShellWithNavScope(
currentLocation: state.matchedLocation,
child: child,
);
},
routes: [
GoRoute(
@@ -58,7 +62,7 @@ class MainNavigationRoutes {
GoRoute(
path: RouteConstants.diaryScreen,
name: 'diaryScreen',
pageBuilder: (context, state) => const NoTransitionPage(child: DiaryScreen()),
pageBuilder: (context, state) => NoTransitionPage(child: DiaryScreen(date: state.extra as DateTime?)),
),
GoRoute(
path: ConsultationsScreen.path,
@@ -68,7 +72,10 @@ class MainNavigationRoutes {
GoRoute(
path: RouteConstants.chatSelectScreen,
name: 'chatSelectScreen',
pageBuilder: (context, state) => const NoTransitionPage(child: ChatsScreen()),
pageBuilder: (context, state) {
final initialSearchQuery = state.extra is String ? state.extra as String : null;
return NoTransitionPage(child: ChatsScreen(initialSearchQuery: initialSearchQuery));
},
),
GoRoute(
path: RouteConstants.notificationsScreen,
@@ -94,3 +101,33 @@ 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,
required this.currentLocation,
});
final Widget child;
final String currentLocation;
@override
State<_MainShellWithNavScope> createState() => _MainShellWithNavScopeState();
}
class _MainShellWithNavScopeState extends State<_MainShellWithNavScope> {
final BottomNavController _navController = BottomNavController();
@override
Widget build(BuildContext context) {
return BottomNavScope(
controller: _navController,
child: BottomNavigationBarWidget(
currentLocation: widget.currentLocation,
child: widget.child,
),
);
}
}

View File

@@ -204,7 +204,7 @@ class OtherRoutes {
GoRoute(
path: '${RouteConstants.subsScreen}/:slug',
name: 'subsScreen',
builder: (context, state) => SubsScreen(slug: state.pathParameters['slug']!, subType: state.extra as SubType),
builder: (context, state) => SubsScreen(slug: state.pathParameters['slug'], subType: state.extra as SubType),
),
// Chat routes
@@ -285,8 +285,7 @@ class OtherRoutes {
builder: (context, state) {
final data = state.extra as Map<String, int>;
final diaryId = data['diaryId'] as int;
final currentPlanDay = data['currentDiaryDay'] as int;
return UserDiaryScreen(diaryId: diaryId, currentPlanDay: currentPlanDay);
return UserDiaryScreen(diaryId: diaryId);
},
),
GoRoute(

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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;

View File

@@ -21,8 +21,9 @@ import 'package:share_plus/share_plus.dart';
class BlogPostScreen extends StatefulWidget {
final int id;
final int? highlightedCommentId;
const BlogPostScreen({super.key, required this.id});
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
@override
State<BlogPostScreen> createState() => _BlogPostScreenState();
@@ -303,7 +304,13 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
const SizedBox(height: 16),
if (state is CommentsStateLoading) const Loader(),
if (state is CommentsStateLoaded)
CommentsWidget(id: widget.id.toString(), commentSourceType: CommentSourceType.blogPost, comment: state.comment, replyId: replyId),
CommentsWidget(
id: widget.id.toString(),
commentSourceType: CommentSourceType.blogPost,
comment: state.comment,
replyId: replyId,
highlightedCommentId: widget.highlightedCommentId,
),
],
),
),

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
class BottomNavController {
BottomNavController();
void Function(String path, Object? extra)? _onSwitchToRoute;
void register(void Function(String path, Object? extra) callback) {
_onSwitchToRoute = callback;
}
void switchToRoute(String path, [Object? extra]) {
_onSwitchToRoute?.call(path, extra);
}
}
class BottomNavScope extends InheritedWidget {
const BottomNavScope({
required this.controller,
required super.child,
super.key,
});
final BottomNavController controller;
static BottomNavController? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<BottomNavScope>()?.controller;
}
static BottomNavController of(BuildContext context) {
final controller = maybeOf(context);
assert(controller != null, 'No BottomNavScope found in context');
return controller!;
}
@override
bool updateShouldNotify(BottomNavScope oldWidget) => controller != oldWidget.controller;
}

View File

@@ -8,6 +8,7 @@ import 'package:eatvio_app/features/notifications/presentation/notifications_cub
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
import 'package:eatvio_app/generated/l10n.dart';
@@ -18,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();
@@ -66,6 +73,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
}
}
/// Switch to the tab that corresponds to [path], optionally with [extra]
/// (e.g. date for diary). If [path] is not in the current tab list, falls back to push.
void _switchToRoute(String path, [Object? extra]) {
if (pages == null) return;
final index = pages!.indexOf(path);
if (index >= 0 && index != currentBottomNavBarIndex) {
setState(() {
currentBottomNavBarIndex = index;
});
GoRouter.of(context).replace(path, extra: extra);
} else if (index >= 0) {
// Same tab but with new extra (e.g. diary with date)
GoRouter.of(context).replace(path, extra: extra);
} else {
GoRouter.of(context).push(path, extra: extra);
}
}
static const Color _devAccentColor = Color(0xFFF59E0B); // amber-500
Widget _buildLogoIcon() {
@@ -121,6 +146,22 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
// Инициализируем pages на основе profileModel
pages ??= _getPages(isConsultant);
// Expose tab switching to descendants (e.g. notification widget)
final controller = BottomNavScope.maybeOf(context);
if (controller != null) {
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(
@@ -129,7 +170,7 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
selectedFontSize: 10,
unselectedFontSize: 10,
selectedItemColor: appTheme.blueA400,
currentIndex: currentBottomNavBarIndex,
currentIndex: effectiveIndex,
items: [
BottomNavigationBarItem(
label: '',
@@ -204,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),
),
@@ -226,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,

View File

@@ -11,7 +11,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ChatsScreen extends StatefulWidget {
const ChatsScreen({super.key});
const ChatsScreen({super.key, this.initialSearchQuery});
/// When set (e.g. from notification tap), the search field is pre-filled
/// and chat list is filtered by this query.
final String? initialSearchQuery;
@override
State<ChatsScreen> createState() => _ChatsScreenState();
@@ -23,12 +27,17 @@ class _ChatsScreenState extends State<ChatsScreen> {
@override
void initState() {
super.initState();
// Инициализация ChatsCubit при первом открытии экрана
final query = widget.initialSearchQuery?.trim();
if (query != null && query.isNotEmpty) {
searchController.text = query;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
final cubit = context.read<ChatsCubit>();
final state = cubit.state;
if (state.state == BaseState.initial) {
cubit.getChatRoomsWithLoading();
cubit.getChatRoomsWithLoading(query: query?.isNotEmpty == true ? query : null);
} else if (query != null && query.isNotEmpty) {
cubit.getChatRooms(query: query);
}
});
}

View File

@@ -172,8 +172,8 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
);
return;
}
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id, 'currentDiaryDay': index + 1});
},
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id,});
},
borderRadius: BorderRadius.circular(100),
child: Ink(
decoration: BoxDecoration(
@@ -255,7 +255,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
.where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished)
.lastOrNull;
if (lastActiveDay != null) {
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id, 'currentDiaryDay': lastActiveDay.$1});
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id,});
return;
}
showErrorDialog(

View File

@@ -48,6 +48,11 @@ class DiaryCubit extends Cubit<DiaryState> {
String comment = '';
void setInitialDate(DateTime? date) {
if (date == null) return;
selectedDay = date;
}
Future<bool> setSelectedDay(DateTime day) async {
final prevDay = selectedDay;
final prevMeal = selectedMeal;
@@ -63,7 +68,7 @@ class DiaryCubit extends Cubit<DiaryState> {
return canSelect;
}
Future<bool> fetchDiaryV2({bool needLoading = false, int? diaryId}) async {
Future<bool> fetchDiaryV2({bool needLoading = false, int? diaryId, DateTime? date}) async {
final currentState = state;
try {
if (needLoading) {
@@ -77,6 +82,7 @@ class DiaryCubit extends Cubit<DiaryState> {
return true;
}
}
final diaryDay = await getDiary();
if (diaryDay == null) {
emit(const DiaryState.error(errorMessage: 'Diary not found'));

View File

@@ -24,31 +24,29 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class DiaryScreen extends StatelessWidget {
const DiaryScreen({super.key});
const DiaryScreen({super.key, required this.date});
final DateTime? date;
@override
Widget build(BuildContext context) {
return const DiaryScreenContent();
return DiaryScreenContent(date: date);
}
}
class UserDiaryScreen extends StatelessWidget {
static const path = '/user-diary-screen';
static const name = 'userDiaryScreen';
const UserDiaryScreen({super.key, required this.diaryId, required this.currentPlanDay});
const UserDiaryScreen({super.key, required this.diaryId});
final int diaryId;
final int currentPlanDay;
@override
Widget build(BuildContext context) {
return DiaryScreenContent(diaryId: diaryId, currentPlanDay: currentPlanDay);
return DiaryScreenContent(diaryId: diaryId,);
}
}
class DiaryScreenContent extends StatefulWidget {
const DiaryScreenContent({super.key, this.diaryId, this.currentPlanDay});
const DiaryScreenContent({super.key, this.diaryId,this.date});
final int? diaryId;
final int? currentPlanDay;
final DateTime? date;
@override
State<DiaryScreenContent> createState() => _DiaryScreenContentState();
}
@@ -61,7 +59,11 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
final s = S.of(context);
return MultiBlocProvider(
providers: [
BlocProvider<DiaryCubit>(create: (BuildContext context) => sl<DiaryCubit>()..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId)),
BlocProvider<DiaryCubit>(
create: (BuildContext context) => sl<DiaryCubit>()
..setInitialDate(widget.date)
..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId),
),
BlocProvider(create: (context) => sl<ConsultantCommentsCubit>()),
],
child: BlocBuilder<DiaryCubit, DiaryState>(
@@ -91,7 +93,7 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
physics: const BouncingScrollPhysics(),
child: Column(
children: [
if (widget.diaryId == null) const CalendarWidget(),
if (widget.diaryId == null) CalendarWidget(date: widget.date),
if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()],
if (state is DiaryStateLoaded)
WakeupAndRetireWidget(

View File

@@ -8,7 +8,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:table_calendar/table_calendar.dart';
class CalendarWidget extends StatefulWidget {
const CalendarWidget({super.key});
const CalendarWidget({super.key, this.date});
final DateTime? date;
@override
State<CalendarWidget> createState() => _CalendarWidgetState();
@@ -18,6 +19,23 @@ class _CalendarWidgetState extends State<CalendarWidget> {
CalendarFormat _calendarFormat = CalendarFormat.week;
DateTime _focusedDay = DateTime.now();
DateTime _selectedDay = DateTime.now();
@override
void initState() {
super.initState();
if (widget.date != null) {
_selectedDay = widget.date!;
_focusedDay = widget.date!;
}
}
@override
void didUpdateWidget(CalendarWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.date != null && widget.date != oldWidget.date) {
_selectedDay = widget.date!;
_focusedDay = widget.date!;
}
}
@override
Widget build(BuildContext context) {

View File

@@ -37,8 +37,9 @@ class AboutDietScreen extends StatefulWidget {
final int? id;
final int? index;
final bool navigateToComments;
final int? highlightedCommentId;
const AboutDietScreen({super.key, required this.index, required this.navigateToComments, required this.slug, required this.id});
const AboutDietScreen({super.key, required this.index, required this.navigateToComments, required this.slug, required this.id, this.highlightedCommentId});
@override
State<AboutDietScreen> createState() => _AboutDietScreenState();
@@ -433,6 +434,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
commentSourceType: CommentSourceType.diet,
comment: state.comment,
replyId: replyId,
highlightedCommentId: widget.highlightedCommentId,
),
],
),

View File

@@ -23,14 +23,60 @@ class CommentsWidget extends StatefulWidget {
final CommentSourceType commentSourceType;
final CommentModel comment;
final int? replyId;
final int? highlightedCommentId;
const CommentsWidget({super.key, required this.id, required this.commentSourceType, required this.comment, this.replyId});
const CommentsWidget({
super.key,
required this.id,
required this.commentSourceType,
required this.comment,
this.replyId,
this.highlightedCommentId,
});
@override
State<CommentsWidget> createState() => _CommentsWidgetState();
}
class _CommentsWidgetState extends State<CommentsWidget> {
final Map<int, GlobalKey> commentKeys = {};
@override
void initState() {
super.initState();
if (widget.highlightedCommentId != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToHighlightedComment();
});
}
}
@override
void didUpdateWidget(CommentsWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.highlightedCommentId != null && widget.highlightedCommentId != oldWidget.highlightedCommentId) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToHighlightedComment();
});
} else if (widget.comment != oldWidget.comment && widget.highlightedCommentId != null) {
// Comments might have just loaded
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToHighlightedComment();
});
}
}
void _scrollToHighlightedComment() {
final key = commentKeys[widget.highlightedCommentId];
if (key != null && key.currentContext != null) {
Scrollable.ensureVisible(
key.currentContext!,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -49,8 +95,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
}
Widget _comment(S s, Comment comment, [double radius = 18, bool isChild = false]) {
final key = commentKeys.putIfAbsent(comment.id, () => GlobalKey());
final isMe = (sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded).profile.slug == comment.authorSlug;
return GestureDetector(
key: key,
onTap: () {
showCommentsActionsBottomSheet(
context,
@@ -83,7 +131,7 @@ class _CommentsWidgetState extends State<CommentsWidget> {
child: Container(
width: MediaQuery.of(context).size.width,
decoration: BoxDecoration(
color: widget.replyId == comment.id ? appTheme.blueA400.withValues(alpha: 0.1) : Colors.white,
color: (widget.replyId == comment.id || widget.highlightedCommentId == comment.id) ? appTheme.blueA400.withValues(alpha: 0.1) : Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Padding(

File diff suppressed because it is too large Load Diff

View File

@@ -267,33 +267,113 @@ class NotificationsCubit extends Cubit<NotificationsState> {
isRead: isRead,
createdAt: createdAt,
),
NotificationType.unknown => UnknownNotification(type: type),
NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.complaintRegistered => ComplaintRegisteredNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.contentUnpublished => ContentUnpublishedNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.contentUnpublishedComplaint => ContentUnpublishedNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.financeQuestionCreated => FinanceQuestionCreatedNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.newChatMessage => NewChatMessageNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.completeRegistration => CompleteRegistrationNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.chosenDietExpired => ChosenDietExpiredNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.motivateUser => MotivateUserNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.commentComplaint => CommentComplaintNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.commentDeleted => CommentDeletedNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.userSubscribed => UserSubscribedNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.unknown => UnknownNotification(type: type, createdAt: parseDate(createdAt ?? jsonEncode(notificationJson))),
};
} catch (e) {
return UnknownNotification(type: NotificationType.unknown);
return UnknownNotification(type: NotificationType.unknown, createdAt: DateTime.now());
}
}
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) {

View File

@@ -413,6 +413,78 @@ class NotificationsService {
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.complaintRegistered => ComplaintRegisteredNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.contentUnpublished => ContentUnpublishedNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.contentUnpublishedComplaint => ContentUnpublishedNotification.fromJson( // Reusing ContentUnpublishedNotification if structure is same, or create specific if different. Assuming similar for now or check backend.
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.financeQuestionCreated => FinanceQuestionCreatedNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.newChatMessage => NewChatMessageNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.completeRegistration => CompleteRegistrationNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.chosenDietExpired => ChosenDietExpiredNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.motivateUser => MotivateUserNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.commentComplaint => CommentComplaintNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.commentDeleted => CommentDeletedNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.userSubscribed => UserSubscribedNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.unknown => UnknownNotification(type: type, createdAt: parseDate(createdAt)),
};
} catch (e) {

View File

@@ -32,10 +32,18 @@ import 'package:go_router/go_router.dart';
import '../../../../core/widgets/option_widget.dart';
class AboutPlanScreen extends StatefulWidget {
const AboutPlanScreen({super.key, required this.slug, required this.navigateToComments, required this.index});
final String slug;
final bool navigateToComments;
final int? index;
final int? highlightedCommentId;
const AboutPlanScreen({
super.key,
required this.slug,
required this.navigateToComments,
required this.index,
this.highlightedCommentId,
});
@override
State<AboutPlanScreen> createState() => _AboutPlanScreenState();
}
@@ -468,13 +476,14 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
),
const SizedBox(height: 16),
if (commentsState is CommentsStateLoading) const Loader(),
if (commentsState is CommentsStateLoaded)
CommentsWidget(
id: state.planModel?.id.toString() ?? '',
commentSourceType: CommentSourceType.plan,
comment: commentsState.comment,
replyId: replyId,
),
if (commentsState is CommentsStateLoaded)
CommentsWidget(
id: state.planModel?.id.toString() ?? '',
commentSourceType: CommentSourceType.plan,
comment: commentsState.comment,
replyId: replyId,
highlightedCommentId: widget.highlightedCommentId,
),
],
),
),

View File

@@ -5,22 +5,30 @@ import 'package:flutter_bloc/flutter_bloc.dart';
class SubCubit extends Cubit<SubState> {
final ProfileRepository profileRepository;
SubCubit({
required this.profileRepository,
}) : super(const SubState());
SubCubit({required this.profileRepository}) : super(const SubState());
Future<void> getSubscriptions(String slug, [String? query]) async {
Future<void> getSubscriptions(String? slug, [String? query]) async {
emit(const SubState.loading());
try {
if (slug == null) {
final myProfile = await profileRepository.getMyProfile();
emit(SubState.loaded(subList: await profileRepository.getSubscriptionsBySlug(myProfile.slug, query)));
return;
}
emit(SubState.loaded(subList: await profileRepository.getSubscriptionsBySlug(slug, query)));
} catch (e) {
emit(SubState.error(errorMessage: e.toString()));
}
}
Future<void> getSubscribers(String slug, [String? query]) async {
Future<void> getSubscribers(String? slug, [String? query]) async {
emit(const SubState.loading());
try {
if (slug == null) {
final myProfile = await profileRepository.getMyProfile();
emit(SubState.loaded(subList: await profileRepository.getSubscribersBySlug(myProfile.slug, query)));
return;
}
emit(SubState.loaded(subList: await profileRepository.getSubscribersBySlug(slug, query)));
} catch (e) {
emit(SubState.error(errorMessage: e.toString()));

View File

@@ -12,7 +12,7 @@ enum SubType { subscribers, subscriptions }
class SubsScreen extends StatefulWidget {
final SubType subType;
final String slug;
final String? slug;
const SubsScreen({
super.key,

View File

@@ -36,8 +36,9 @@ class AboutRecipeScreen extends StatefulWidget {
final String recipeSlug;
final int? index;
final bool navigateToComments;
final int? highlightedCommentId;
const AboutRecipeScreen({super.key, required this.recipeSlug, required this.index, required this.navigateToComments});
const AboutRecipeScreen({super.key, required this.recipeSlug, required this.index, required this.navigateToComments, this.highlightedCommentId});
@override
State<AboutRecipeScreen> createState() => _AboutRecipeScreenState();
@@ -391,6 +392,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
commentSourceType: CommentSourceType.recipe,
comment: state.comment,
replyId: replyId,
highlightedCommentId: widget.highlightedCommentId,
),
],
),

View File

@@ -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(

View File

@@ -109,7 +109,10 @@ void main() {
],
child: wrap(
const Scaffold(
body: BottomNavigationBarWidget(child: Text('Test Content')),
body: BottomNavigationBarWidget(
currentLocation: '/feed-screen',
child: Text('Test Content'),
),
),
),
),
@@ -145,7 +148,10 @@ void main() {
],
child: wrap(
const Scaffold(
body: BottomNavigationBarWidget(child: Text('Test Content')),
body: BottomNavigationBarWidget(
currentLocation: '/feed-screen',
child: Text('Test Content'),
),
),
),
),

View File

@@ -88,7 +88,7 @@ void main() {
when(diaryCubit.state).thenReturn(const DiaryState.loading());
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'), date: anyNamed('date'))).thenAnswer((_) async => true);
final commentsCubit = MockConsultantCommentsCubit();
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
@@ -98,7 +98,7 @@ void main() {
sl.registerFactory<DiaryCubit>(() => diaryCubit);
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
await tester.pumpWidget(wrap(const DiaryScreen()));
await tester.pumpWidget(wrap(DiaryScreen(date: DateTime(2025, 1, 1))));
await tester.pump();
expect(find.byType(DiaryScreen), findsOneWidget);
@@ -109,7 +109,7 @@ void main() {
when(diaryCubit.state).thenReturn(loadedState(hasChosenPlan: true));
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'), date: anyNamed('date'))).thenAnswer((_) async => true);
final commentsCubit = MockConsultantCommentsCubit();
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: ''), dayCommentNeedsToBeFilled: false));
@@ -119,7 +119,7 @@ void main() {
sl.registerFactory<DiaryCubit>(() => diaryCubit);
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
await tester.pumpWidget(wrap(const DiaryScreen()));
await tester.pumpWidget(wrap(DiaryScreen(date: DateTime(2025, 1, 1))));
await tester.pumpAndSettle(const Duration(milliseconds: 300));
expect(find.byType(DiaryScreen), findsOneWidget);
@@ -132,7 +132,7 @@ void main() {
when(diaryCubit.state).thenReturn(const DiaryState.error(errorMessage: 'err'));
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'), date: anyNamed('date'))).thenAnswer((_) async => true);
final commentsCubit = MockConsultantCommentsCubit();
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
@@ -142,7 +142,7 @@ void main() {
sl.registerFactory<DiaryCubit>(() => diaryCubit);
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
await tester.pumpWidget(wrap(const DiaryScreen()));
await tester.pumpWidget(wrap(DiaryScreen(date: DateTime(2025, 1, 1))));
await tester.pump();
expect(find.byType(DiaryScreen), findsOneWidget);
@@ -161,7 +161,7 @@ void main() {
when(diaryCubit.state).thenReturn(DiaryState.loadedTomorrowWithDiet(diary: futureDiary, selectedDay: DateTime(2025, 1, 2), selectedMeal: 1));
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'), date: anyNamed('date'))).thenAnswer((_) async => true);
final commentsCubit = MockConsultantCommentsCubit();
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
@@ -171,7 +171,7 @@ void main() {
sl.registerFactory<DiaryCubit>(() => diaryCubit);
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
await tester.pumpWidget(wrap(const DiaryScreen()));
await tester.pumpWidget(wrap(DiaryScreen(date: DateTime(2025, 1, 1))));
await tester.pump(const Duration(milliseconds: 200));
expect(find.byType(DiaryScreen), findsOneWidget);
@@ -193,7 +193,7 @@ void main() {
when(diaryCubit.state).thenReturn(state);
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'), date: anyNamed('date'))).thenAnswer((_) async => true);
final commentsCubit = MockConsultantCommentsCubit();
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: ''), dayCommentNeedsToBeFilled: false));
@@ -204,7 +204,7 @@ void main() {
sl.registerFactory<DiaryCubit>(() => diaryCubit);
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
await tester.pumpWidget(wrap(const DiaryScreen()));
await tester.pumpWidget(wrap(DiaryScreen(date: DateTime(2025, 1, 1))));
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('Nice'), findsOneWidget);
@@ -217,7 +217,7 @@ void main() {
when(diaryCubit.state).thenReturn(state);
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'), date: anyNamed('date'))).thenAnswer((_) async => true);
final commentsCubit = MockConsultantCommentsCubit();
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
@@ -227,7 +227,7 @@ void main() {
sl.registerFactory<DiaryCubit>(() => diaryCubit);
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
await tester.pumpWidget(wrap(const UserDiaryScreen(diaryId: 1, currentPlanDay: 1)));
await tester.pumpWidget(wrap(const UserDiaryScreen(diaryId: 1)));
await tester.pump(const Duration(milliseconds: 200));
expect(find.byType(AppBar), findsWidgets);

View File

@@ -101,7 +101,7 @@ void main() {
registerMocks(cubit, commentsCubit);
await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false)));
await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false, highlightedCommentId: null)));
await tester.pump();
expect(find.byType(AboutDietScreen), findsOneWidget);
@@ -127,7 +127,7 @@ void main() {
registerMocks(cubit, commentsCubit);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false)));
await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false, highlightedCommentId: null)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 600));
});
@@ -157,7 +157,7 @@ void main() {
registerMocks(cubit, commentsCubit);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false)));
await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false, highlightedCommentId: null)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 600));
});
@@ -183,7 +183,7 @@ void main() {
registerMocks(cubit, commentsCubit);
await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false)));
await tester.pumpWidget(wrap(cubit, feedCubit, commentsCubit, const AboutDietScreen(slug: 'test', id: null, index: null, navigateToComments: false, highlightedCommentId: null)));
await tester.pump();
expect(find.byType(AboutDietScreen), findsOneWidget);

View File

@@ -124,7 +124,7 @@ void main() {
});
test('UnknownNotification uses default createdAt when not provided', () {
final notification = UnknownNotification(type: NotificationType.unknown);
final notification = UnknownNotification(type: NotificationType.unknown, createdAt: DateTime.now());
expect(notification.type, NotificationType.unknown);
expect(notification.isRead, true);
@@ -485,8 +485,9 @@ void main() {
test('PersonalDietCreationNeededNotification.fromJson parses all fields', () {
final json = {
'plan_name': 'Custom Plan',
'client_name': 'John Doe',
'user_name': 'John Doe',
'consultant_name': 'Consultant',
'my_diets_url': 'https://example.com/diets',
'created_at': '2024-01-20T12:00:00Z',
};
@@ -496,8 +497,9 @@ void main() {
isRead: true,
);
expect(notification.planName, 'Custom Plan');
expect(notification.clientName, 'John Doe');
expect(notification.userName, 'John Doe');
expect(notification.consultantName, 'Consultant');
expect(notification.myDietsUrl, 'https://example.com/diets');
expect(notification.isRead, true);
expect(notification.type, NotificationType.personalDietCreationNeeded);
});
@@ -510,8 +512,8 @@ void main() {
NotificationType.personalDietCreationNeeded,
);
expect(notification.planName, '');
expect(notification.clientName, '');
expect(notification.userName, '');
expect(notification.consultantName, '');
});
test('ChosenPlanClientLastDayFinishedNotification.fromJson parses all fields', () {
@@ -551,7 +553,9 @@ void main() {
test('StayOnTrackToAuthorNotification.fromJson parses all fields', () {
final json = {
'plan_name': 'Track Plan',
'client_name': 'Jane Smith',
'user_name': 'Jane Smith',
'day_num': 1,
'diary_day_id': 10,
'created_at': '2024-01-24T12:00:00Z',
};
@@ -562,7 +566,7 @@ void main() {
);
expect(notification.planName, 'Track Plan');
expect(notification.clientName, 'Jane Smith');
expect(notification.userName, 'Jane Smith');
expect(notification.isRead, false);
expect(notification.type, NotificationType.stayOnTrackToAuthor);
});

View File

@@ -50,6 +50,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Test Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -89,6 +91,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 1',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -99,6 +103,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 2',
planId: 2,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -143,6 +149,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 1',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -168,6 +176,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 1',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -201,6 +211,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 1',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -231,6 +243,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 1',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -272,6 +286,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 1',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -311,6 +327,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 1',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -342,6 +360,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 1',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -349,6 +369,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 2',
planId: 2,
consultantName: '',
peerProfileUrl: '',
isRead: true,
createdAt: DateTime.now(),
),
@@ -376,6 +398,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 1',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: true,
createdAt: DateTime.now(),
),
@@ -383,6 +407,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 2',
planId: 2,
consultantName: '',
peerProfileUrl: '',
isRead: true,
createdAt: DateTime.now(),
),
@@ -426,6 +452,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Plan 1',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -475,6 +503,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -524,6 +554,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -568,6 +600,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -608,6 +642,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -645,6 +681,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -688,6 +726,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -757,6 +797,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -800,6 +842,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -843,6 +887,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -886,6 +932,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -929,6 +977,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -972,6 +1022,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -1015,6 +1067,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -1058,6 +1112,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -1100,6 +1156,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -1137,6 +1195,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -1174,6 +1234,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),
@@ -1217,6 +1279,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Initial Plan',
planId: 1,
consultantName: '',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),

View File

@@ -36,8 +36,12 @@ void main() {
final notification = PlanSubscriptionStartedToAuthorNotification(
type: NotificationType.planSubscriptionStartedToAuthor,
planName: 'Test Plan',
planId: 1,
customerName: 'Test Customer',
userId: 0,
userPhotoPath: '',
chatId: 'chat-123',
chatUrl: '',
isRead: false,
createdAt: DateTime.now(),
);

View File

@@ -31,8 +31,12 @@ void main() {
final notification = PlanSubscriptionStartedToAuthorNotification(
type: NotificationType.planSubscriptionStartedToAuthor,
planName: 'Test Plan',
planId: 1,
customerName: 'Test Customer',
userId: 0,
userPhotoPath: '',
chatId: 'chat123',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -47,7 +51,11 @@ void main() {
final notification = PlanSubscriptionCanceledToAuthorNotification(
type: NotificationType.planSubscriptionCanceledToAuthor,
planName: 'Test Plan',
planId: 1,
customerName: 'Test Customer',
userId: 0,
userPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -63,6 +71,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClient,
planName: 'Test Plan',
planId: 1,
consultantName: 'Test Consultant',
peerProfileUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -77,6 +87,8 @@ void main() {
final notification = ChosenPlanTomorrowReminderToClientNotification(
type: NotificationType.chosenPlanTomorrowReminderToClient,
planName: 'Test Plan',
consultantName: '',
chatId: 0,
isRead: false,
createdAt: testDate,
);
@@ -103,7 +115,12 @@ void main() {
testWidgets('displays ConsultantLeftCommentNotification', (tester) async {
final notification = ConsultantLeftCommentNotification(
type: NotificationType.consultantLeftComment,
chosenPlanId: 1,
diaryId: 0,
date: testDate,
planName: '',
userName: '',
consultantName: '',
isRead: false,
createdAt: testDate,
);
@@ -131,6 +148,11 @@ void main() {
final notification = ChosenPlanFinishedToClientNotification(
type: NotificationType.chosenPlanFinishedToClient,
planName: 'Test Plan',
chosenPlanId: 1,
consultantName: '',
consultantPhotoPath: '',
consultantProfileUrl: '',
consultantId: 0,
isRead: false,
createdAt: testDate,
);
@@ -145,6 +167,11 @@ void main() {
final notification = PlanSubscriptionCanceledToCustomerNotification(
type: NotificationType.planSubscriptionCanceledToCustomer,
planName: 'Test Plan',
planId: 1,
authorName: '',
authorId: 0,
authorPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -160,6 +187,10 @@ void main() {
type: NotificationType.chosenPlanStartedToConsultant,
planName: 'Test Plan',
customerName: 'Test Customer',
planId: 1,
customerId: 1,
peerProfileUrl: '',
consultationUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -175,6 +206,7 @@ void main() {
type: NotificationType.chosenPlanTomorrowReminderToConsultant,
planName: 'Test Plan',
customerName: 'Test Customer',
chatId: 0,
isRead: false,
createdAt: testDate,
);
@@ -189,7 +221,11 @@ void main() {
final notification = PlanSubscriptionPaidToAuthorNotification(
type: NotificationType.planSubscriptionPaidToAuthor,
planName: 'Test Plan',
planId: 1,
customerName: 'Test Customer',
userId: 0,
userPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -206,6 +242,9 @@ void main() {
planName: 'Test Plan',
customerName: 'Test Customer',
planId: 1,
chosenPlanId: 1,
clientPhotoPath: '',
clientId: 0,
isRead: false,
createdAt: testDate,
);
@@ -219,6 +258,10 @@ void main() {
testWidgets('displays ChosenPlanLastDayFinishedForUser', (tester) async {
final notification = ChosenPlanLastDayFinishedForUser(
type: NotificationType.chosenPlanLastDayFinishedForUser,
planName: 'Test Plan',
chosenPlanId: 1,
complaintUrl: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -252,6 +295,7 @@ void main() {
message: 'Test message',
entityId: 1,
entityType: 'test',
entityName: 'Test Entity',
isRead: false,
createdAt: testDate,
);
@@ -267,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,
@@ -305,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',
@@ -470,6 +514,7 @@ void main() {
final notification = DietCancelledNotification(
type: NotificationType.dietCancelled,
dietName: 'Test Diet',
dietId: 1,
isRead: false,
createdAt: testDate,
);
@@ -484,6 +529,7 @@ void main() {
final notification = DietStartedNotification(
type: NotificationType.dietStarted,
dietName: 'Test Diet',
dietId: 1,
isRead: false,
createdAt: testDate,
);
@@ -498,6 +544,7 @@ void main() {
final notification = DietExpiredNotification(
type: NotificationType.dietExpired,
dietName: 'Test Diet',
dietId: 1,
isRead: false,
createdAt: testDate,
);
@@ -512,6 +559,7 @@ void main() {
final notification = DietWillStartedInNotification(
type: NotificationType.dietWillStartedIn,
dietName: 'Test Diet',
dietId: 1,
startDate: '2024-02-01',
isRead: false,
createdAt: testDate,
@@ -527,6 +575,7 @@ void main() {
final notification = DietCanNotBeStartedNotification(
type: NotificationType.dietCanNotBeStarted,
dietName: 'Test Diet',
dietId: 1,
reason: 'Test reason',
isRead: false,
createdAt: testDate,
@@ -541,8 +590,9 @@ void main() {
testWidgets('displays PersonalDietCreationNeededNotification', (tester) async {
final notification = PersonalDietCreationNeededNotification(
type: NotificationType.personalDietCreationNeeded,
planName: 'Test Plan',
clientName: 'Test Client',
userName: 'Test Client',
consultantName: '',
myDietsUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -557,6 +607,9 @@ void main() {
final notification = ChosenPlanClientLastDayFinishedNotification(
type: NotificationType.chosenPlanClientLastDayFinished,
planName: 'Test Plan',
chosenPlanId: 1,
complaintUrl: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -571,6 +624,8 @@ void main() {
final notification = ChosenPlanClientLastDayStartedNotification(
type: NotificationType.chosenPlanClientLastDayStarted,
planName: 'Test Plan',
chosenPlanId: 1,
lastDayDiaryUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -585,7 +640,9 @@ void main() {
final notification = StayOnTrackToAuthorNotification(
type: NotificationType.stayOnTrackToAuthor,
planName: 'Test Plan',
clientName: 'Test Client',
userName: 'Test Client',
dayNum: 1,
diaryDayId: 0,
isRead: false,
createdAt: testDate,
);
@@ -600,6 +657,13 @@ void main() {
final notification = ClientStartPlanSubscriptionToSubscriberNotification(
type: NotificationType.clientStartPlanSubscriptionToSubscriber,
planName: 'Test Plan',
planId: 1,
authorName: '',
authorId: 0,
authorPhotoPath: '',
chatUrl: '',
chatId: '',
startDate: '',
isRead: false,
createdAt: testDate,
);
@@ -614,7 +678,11 @@ void main() {
final notification = ConsultantConfirmPlanSubscriptionToSubscriberNotification(
type: NotificationType.consultantConfirmPlanSubscriptionToSubscriber,
planName: 'Test Plan',
planId: 1,
consultantName: 'Test Consultant',
authorId: 0,
authorPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -629,8 +697,11 @@ void main() {
final notification = ClientPaidPlanSubscriptionToAuthorNotification(
type: NotificationType.clientPaidPlanSubscriptionToAuthor,
planName: 'Test Plan',
planId: 1,
clientName: 'Test Client',
amount: 100.0,
userId: 0,
userPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -644,7 +715,10 @@ void main() {
testWidgets('displays ConsultantLeftDiaryCommentNotification', (tester) async {
final notification = ConsultantLeftDiaryCommentNotification(
type: NotificationType.consultantLeftDiaryComment,
chosenPlanId: 1,
diaryId: 0,
planName: 'Test Plan',
userName: '',
consultantName: 'Test Consultant',
diaryDate: testDate,
isRead: false,
@@ -661,7 +735,11 @@ void main() {
final notification = ConsultantConfirmPlanSubscriptionToAuthorNotification(
type: NotificationType.consultantConfirmPlanSubscriptionToAuthor,
planName: 'Test Plan',
consultantName: 'Test Consultant',
planId: 1,
clientName: 'Test Consultant',
userId: 0,
userPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -676,7 +754,11 @@ void main() {
final notification = ClientPaidPlanSubscriptionToSubscriberNotification(
type: NotificationType.clientPaidPlanSubscriptionToSubscriber,
planName: 'Test Plan',
amount: 100.0,
planId: 1,
authorName: '',
authorId: 0,
authorPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -691,7 +773,12 @@ void main() {
final notification = ClientStartPlanSubscriptionToAuthorNotification(
type: NotificationType.clientStartPlanSubscriptionToAuthor,
planName: 'Test Plan',
planId: 1,
clientName: 'Test Client',
userId: 0,
userPhotoPath: '',
chatId: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -706,7 +793,11 @@ void main() {
final notification = ClientCancelPlanSubscriptionToAuthorNotification(
type: NotificationType.clientCancelPlanSubscriptionToAuthor,
planName: 'Test Plan',
planId: 1,
clientName: 'Test Client',
userId: 0,
userPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -721,6 +812,11 @@ void main() {
final notification = ClientCancelPlanSubscriptionToSubscriberNotification(
type: NotificationType.clientCancelPlanSubscriptionToSubscriber,
planName: 'Test Plan',
planId: 1,
authorName: '',
authorId: 0,
authorPhotoPath: '',
chatUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -734,9 +830,11 @@ void main() {
testWidgets('displays UserDailyReportCompletedNotification', (tester) async {
final notification = UserDailyReportCompletedNotification(
type: NotificationType.userDailyReportCompleted,
chosenPlanId: 1,
diaryId: 0,
planName: 'Test Plan',
clientName: 'Test Client',
reportDate: testDate,
consultantName: '',
isRead: false,
createdAt: testDate,
);
@@ -751,7 +849,8 @@ void main() {
final notification = StayOnTrackToCustomerNotification(
type: NotificationType.stayOnTrackToCustomer,
planName: 'Test Plan',
consultantName: 'Test Consultant',
dayNum: 1,
diaryDayId: 0,
isRead: false,
createdAt: testDate,
);
@@ -780,8 +879,9 @@ void main() {
testWidgets('displays SubscriptionNotification', (tester) async {
final notification = SubscriptionNotification(
type: NotificationType.subscription,
subscriptionType: 'monthly',
planName: 'Test Plan',
userName: '',
userPhotoPath: '',
userProfileUrl: '',
isRead: false,
createdAt: testDate,
);
@@ -795,8 +895,9 @@ void main() {
testWidgets('displays BadgeAwardedNotification', (tester) async {
final notification = BadgeAwardedNotification(
type: NotificationType.badgeAwarded,
badgeName: 'Test Badge',
badgeDescription: 'Test Description',
badgeTitle: 'Test Badge',
badgeType: '',
badgeImage: '',
isRead: false,
createdAt: testDate,
);
@@ -810,8 +911,9 @@ void main() {
testWidgets('displays BadgeRevokedNotification', (tester) async {
final notification = BadgeRevokedNotification(
type: NotificationType.badgeRevoked,
badgeName: 'Test Badge',
reason: 'Test reason',
badgeTitle: 'Test Badge',
badgeType: '',
badgeImage: '',
isRead: false,
createdAt: testDate,
);
@@ -839,8 +941,7 @@ void main() {
testWidgets('displays ClientCloseComplaintSuccessNotification', (tester) async {
final notification = ClientCloseComplaintSuccessNotification(
type: NotificationType.clientCloseComplaintSuccess,
complaintType: 'test',
entityName: 'Test Entity',
planName: 'Test Plan',
isRead: false,
createdAt: testDate,
);
@@ -887,7 +988,6 @@ void main() {
final notification = ClientRejectSuccessNotification(
type: NotificationType.clientRejectSuccess,
planName: 'Test Plan',
reason: 'Test reason',
isRead: false,
createdAt: testDate,
);
@@ -903,7 +1003,6 @@ void main() {
type: NotificationType.consultantRejectSuccess,
planName: 'Test Plan',
clientName: 'Test Client',
reason: 'Test reason',
isRead: false,
createdAt: testDate,
);
@@ -937,8 +1036,12 @@ void main() {
final notification = ProductStatusUpdatedForAuthorNotification(
type: NotificationType.productStatusUpdatedForAuthor,
productName: 'Test Product',
oldStatus: 'pending',
newStatus: 'approved',
productId: 1,
author: '',
authorId: 0,
status: 'approved',
subject: '',
messageLine: '',
isRead: false,
createdAt: testDate,
);
@@ -967,8 +1070,15 @@ void main() {
type: NotificationType.personalDietIsDefinedToCustomer,
isRead: false,
createdAt: testDate,
dietName: 'Test Diet',
chosenPlanId: 1,
dietId: 1,
planName: '',
userName: '',
consultantName: '',
dietName: 'Test Diet',
role: '',
peerSlug: '',
peerImagePath: '',
);
await tester.pumpWidget(createTestWidget(notification));

View File

@@ -24,6 +24,8 @@ void main() {
type: NotificationType.chosenPlanStartedToClientNotification,
planName: 'Test Plan',
planId: 1,
consultantName: 'Test Consultant',
peerProfileUrl: '',
isRead: false,
createdAt: DateTime.now(),
),

View File

@@ -123,7 +123,7 @@ void main() {
registerMocks(planCubit, commentsCubit);
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null)));
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null)));
await tester.pump();
expect(find.byType(AboutPlanScreen), findsOneWidget);
@@ -149,7 +149,7 @@ void main() {
registerMocks(planCubit, commentsCubit);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null)));
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null)));
await tester.pump();
await tester.pump();
});
@@ -177,7 +177,7 @@ void main() {
registerMocks(planCubit, commentsCubit);
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null)));
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null)));
await tester.pump();
expect(find.byType(AboutPlanScreen), findsOneWidget);