From bb97c395dded9828966fa49c74fb41f696eb9349 Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Thu, 12 Feb 2026 11:25:06 +0300 Subject: [PATCH 1/9] feat: Implement comprehensive navigation logic for various notification types within the NotificationWidget. --- .gitignore | 3 +- lib/core/routing/content_routes.dart | 66 +- .../presentation/pages/blog_post_screen.dart | 11 +- .../pages/blog_post_create_screen.dart | 1 + .../presentation/pages/about_diet_screen.dart | 4 +- .../presentation/widgets/comments_widget.dart | 52 +- .../models/notification_model.dart | 392 +++++++++++- .../notifications_cubit.dart | 76 ++- .../widgets/notification_widget.dart | 589 ++++++++++++++++-- .../service/notifications_service.dart | 72 +++ .../presentation/pages/about_plan_screen.dart | 25 +- .../pages/about_recipe_screen.dart | 4 +- .../pages/about_diet_screen_smoke_test.dart | 8 +- .../models/notification_model_test.dart | 2 +- .../widgets/notification_widget_test.dart | 6 + .../pages/about_plan_screen_smoke_test.dart | 6 +- 16 files changed, 1211 insertions(+), 106 deletions(-) diff --git a/.gitignore b/.gitignore index 530ea713..1503b5ce 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ env/.env .env android/key.properties app/upload-keystore.jks -coverage/ \ No newline at end of file +coverage/ +backend/ \ No newline at end of file diff --git a/lib/core/routing/content_routes.dart b/lib/core/routing/content_routes.dart index 0556fab6..5a93013c 100644 --- a/lib/core/routing/content_routes.dart +++ b/lib/core/routing/content_routes.dart @@ -17,12 +17,25 @@ 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; - 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 +43,25 @@ 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; - 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,15 +69,25 @@ class ContentRoutes { name: 'aboutDietScreen', builder: (context, state) { final slug = state.uri.queryParameters['slug']; + 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; return AboutDietScreen( id: data['id'] as int?, slug: data['slug'] as String?, index: data['index'] as int?, - navigateToComments: data['navigateToComments'] as bool, + navigateToComments: data['navigateToComments'] as bool? ?? (data['commentId'] != null), + highlightedCommentId: data['commentId'] as int?, ); }, ), @@ -65,12 +101,26 @@ 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; + return BlogPostScreen( + id: data['id'] as int, + highlightedCommentId: data['commentId'] as int?, + ); }, ), ]; diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index 70593f8c..bf50518a 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -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 createState() => _BlogPostScreenState(); @@ -303,7 +304,13 @@ class _BlogPostScreenState extends State { 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, + ), ], ), ), diff --git a/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart b/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart index 0254d3c7..6da09b9e 100644 --- a/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart +++ b/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart @@ -568,6 +568,7 @@ class _BlogPostCreateScreenState extends State { CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController), const SizedBox(height: 80), _createBlogPostButton(), + const SizedBox(height: 80), ], ), ), diff --git a/lib/features/diets/presentation/pages/about_diet_screen.dart b/lib/features/diets/presentation/pages/about_diet_screen.dart index 54d74215..3823b471 100644 --- a/lib/features/diets/presentation/pages/about_diet_screen.dart +++ b/lib/features/diets/presentation/pages/about_diet_screen.dart @@ -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 createState() => _AboutDietScreenState(); @@ -433,6 +434,7 @@ class _AboutDietScreenState extends State { commentSourceType: CommentSourceType.diet, comment: state.comment, replyId: replyId, + highlightedCommentId: widget.highlightedCommentId, ), ], ), diff --git a/lib/features/feed/presentation/widgets/comments_widget.dart b/lib/features/feed/presentation/widgets/comments_widget.dart index f8b3ecba..61ba39d2 100644 --- a/lib/features/feed/presentation/widgets/comments_widget.dart +++ b/lib/features/feed/presentation/widgets/comments_widget.dart @@ -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 createState() => _CommentsWidgetState(); } class _CommentsWidgetState extends State { + final Map 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 { } Widget _comment(S s, Comment comment, [double radius = 18, bool isChild = false]) { + final key = commentKeys.putIfAbsent(comment.id, () => GlobalKey()); final isMe = (sl().state as ProfileAvatarStateLoaded).profile.slug == comment.authorSlug; return GestureDetector( + key: key, onTap: () { showCommentsActionsBottomSheet( context, @@ -83,7 +131,7 @@ class _CommentsWidgetState extends State { 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( diff --git a/lib/features/notifications/models/notification_model.dart b/lib/features/notifications/models/notification_model.dart index cc77616e..3375a312 100644 --- a/lib/features/notifications/models/notification_model.dart +++ b/lib/features/notifications/models/notification_model.dart @@ -215,6 +215,8 @@ class ChosenPlanStartedToConsultantNotification implements BaseNotificationModel final bool isRead; final String planName; final String customerName; + final int planId; + final int customerId; @override final DateTime createdAt; ChosenPlanStartedToConsultantNotification({ @@ -222,6 +224,8 @@ class ChosenPlanStartedToConsultantNotification implements BaseNotificationModel required this.isRead, required this.planName, required this.customerName, + required this.planId, + required this.customerId, required this.createdAt, }); @@ -231,6 +235,8 @@ class ChosenPlanStartedToConsultantNotification implements BaseNotificationModel isRead: isRead ?? false, planName: json['plan_name'], customerName: json['client_name'], + planId: json['plan_id'] ?? 0, + customerId: json['client_id'] ?? 0, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } @@ -420,6 +426,7 @@ class CommentReplied implements BaseNotificationModel { final String author; final int authorId; final int parentCommentId; + final int? commentId; @override final DateTime createdAt; @@ -431,6 +438,7 @@ class CommentReplied implements BaseNotificationModel { required this.author, required this.authorId, required this.parentCommentId, + this.commentId, required this.isRead, required this.createdAt, }); @@ -444,6 +452,7 @@ class CommentReplied implements BaseNotificationModel { author: json['author'], authorId: json['author_id'], parentCommentId: json['parrent_comment_id'], + commentId: json['comment_id'], isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -504,6 +513,7 @@ class EntityCommented implements BaseNotificationModel { final String authorName; final int authorId; final String authorSlug; + final int? commentId; @override final DateTime createdAt; @@ -517,6 +527,7 @@ class EntityCommented implements BaseNotificationModel { required this.authorName, required this.authorId, required this.authorSlug, + this.commentId, required this.isRead, required this.createdAt, }); @@ -532,6 +543,7 @@ class EntityCommented implements BaseNotificationModel { authorName: json['author_name'], authorId: json['author_id'], authorSlug: json['author_slug'], + commentId: json['comment_id'], isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -831,7 +843,15 @@ class UnknownNotification implements BaseNotificationModel { @override final DateTime createdAt; - UnknownNotification({required this.type, this.isRead = true, DateTime? createdAt}) : createdAt = createdAt ?? DateTime.fromMillisecondsSinceEpoch(0); + UnknownNotification({required this.type, this.isRead = true, required this.createdAt}); + + factory UnknownNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return UnknownNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } } class DietCancelledNotification implements BaseNotificationModel { @@ -1015,16 +1035,28 @@ class StayOnTrackToAuthorNotification implements BaseNotificationModel { final bool isRead; final String planName; final String clientName; + final int planId; + final int clientId; @override final DateTime createdAt; - StayOnTrackToAuthorNotification({required this.type, required this.planName, required this.clientName, required this.isRead, required this.createdAt}); + StayOnTrackToAuthorNotification({ + required this.type, + required this.planName, + required this.clientName, + required this.planId, + required this.clientId, + required this.isRead, + required this.createdAt, + }); factory StayOnTrackToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return StayOnTrackToAuthorNotification( type: type, planName: json['plan_name'] ?? '', clientName: json['client_name'] ?? '', + planId: json['plan_id'] ?? 0, + clientId: json['client_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1326,16 +1358,28 @@ class StayOnTrackToCustomerNotification implements BaseNotificationModel { final bool isRead; final String planName; final String consultantName; + final int planId; + final int consultantId; @override final DateTime createdAt; - StayOnTrackToCustomerNotification({required this.type, required this.planName, required this.consultantName, required this.isRead, required this.createdAt}); + StayOnTrackToCustomerNotification({ + required this.type, + required this.planName, + required this.consultantName, + required this.planId, + required this.consultantId, + required this.isRead, + required this.createdAt, + }); factory StayOnTrackToCustomerNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return StayOnTrackToCustomerNotification( type: type, planName: json['plan_name'] ?? '', consultantName: json['consultant_name'] ?? '', + planId: json['plan_id'] ?? 0, + consultantId: json['consultant_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1652,14 +1696,16 @@ class ProductStatusUpdatedForAuthorNotification implements BaseNotificationModel final String productName; final String oldStatus; final String newStatus; + final String status; // Added to capture single status if old/new aren't provided @override final DateTime createdAt; ProductStatusUpdatedForAuthorNotification({ required this.type, required this.productName, - required this.oldStatus, - required this.newStatus, + this.oldStatus = '', + this.newStatus = '', + this.status = '', required this.isRead, required this.createdAt, }); @@ -1670,6 +1716,7 @@ class ProductStatusUpdatedForAuthorNotification implements BaseNotificationModel productName: json['product_name'] ?? '', oldStatus: json['old_status'] ?? '', newStatus: json['new_status'] ?? '', + status: json['status'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1724,6 +1771,309 @@ class PersonalDietIsDefinedToCustomerNotification implements BaseNotificationMod } } +// New Model Classes for Comprehensive Support + +class ConsultantEducationUpdatedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String consultantName; + + ConsultantEducationUpdatedNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.consultantName, + }); + + factory ConsultantEducationUpdatedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ConsultantEducationUpdatedNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + consultantName: json['consultant_name'] ?? '', + ); + } +} + +class ComplaintRegisteredNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String complaintType; + final String entityName; + + ComplaintRegisteredNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.complaintType, + required this.entityName, + }); + + factory ComplaintRegisteredNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ComplaintRegisteredNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + complaintType: json['complaint_type'] ?? '', + entityName: json['entity_name'] ?? '', + ); + } +} + +class ContentUnpublishedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String reason; + final String entityName; + + ContentUnpublishedNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.reason, + required this.entityName, + }); + + factory ContentUnpublishedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ContentUnpublishedNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + reason: json['reason'] ?? '', + entityName: json['entity_name'] ?? '', + ); + } +} + +class FinanceQuestionCreatedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String questionText; + + FinanceQuestionCreatedNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.questionText, + }); + + factory FinanceQuestionCreatedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return FinanceQuestionCreatedNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + questionText: json['question_text'] ?? '', + ); + } +} + +class NewChatMessageNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String senderName; + final String messagePreview; + final int chatId; + + NewChatMessageNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.senderName, + required this.messagePreview, + required this.chatId, + }); + + factory NewChatMessageNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return NewChatMessageNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + senderName: json['sender_name'] ?? '', + messagePreview: json['message_preview'] ?? '', + chatId: json['chat_id'] ?? 0, + ); + } +} + +class CompleteRegistrationNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + + CompleteRegistrationNotification({ + required this.type, + required this.isRead, + required this.createdAt, + }); + + factory CompleteRegistrationNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return CompleteRegistrationNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + ); + } +} + +class ChosenDietExpiredNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String dietName; + + ChosenDietExpiredNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.dietName, + }); + + factory ChosenDietExpiredNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return ChosenDietExpiredNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + dietName: json['diet_name'] ?? '', + ); + } +} + +class MotivateUserNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String message; + + MotivateUserNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.message, + }); + + factory MotivateUserNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return MotivateUserNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + message: json['message'] ?? '', + ); + } +} + +class CommentComplaintNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String commentPreview; + final String reason; + + CommentComplaintNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.commentPreview, + required this.reason, + }); + + factory CommentComplaintNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return CommentComplaintNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + commentPreview: json['comment_preview'] ?? '', + reason: json['reason'] ?? '', + ); + } +} + +class CommentDeletedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String commentPreview; + + CommentDeletedNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.commentPreview, + }); + + factory CommentDeletedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return CommentDeletedNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + commentPreview: json['comment_preview'] ?? '', + ); + } +} + +class UserSubscribedNotification implements BaseNotificationModel { + @override + final NotificationType type; + @override + final bool isRead; + @override + final DateTime createdAt; + final String subscriberName; + final int subscriberId; + + UserSubscribedNotification({ + required this.type, + required this.isRead, + required this.createdAt, + required this.subscriberName, + required this.subscriberId, + }); + + factory UserSubscribedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + return UserSubscribedNotification( + type: type, + isRead: isRead ?? false, + createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + subscriberName: json['subscriber_name'] ?? '', + subscriberId: json['subscriber_id'] ?? 0, + ); + } +} + enum NotificationType { // Diet related dietCancelled, @@ -1731,6 +2081,7 @@ enum NotificationType { dietExpired, dietWillStartedIn, dietCanNotBeStarted, + chosenDietExpired, // New // Plan related personalDietCreationNeeded, @@ -1760,12 +2111,17 @@ enum NotificationType { // Comments entityCommented, commentReplied, + commentComplaint, // New + commentDeleted, // New // Profile phoneChanged, + consultantEducationUpdated, // New + completeRegistration, // New // Subscription subscription, + userSubscribed, // New // Badges badgeAwarded, @@ -1773,16 +2129,21 @@ enum NotificationType { // Consultant consultantRequestStarted, + motivateUser, // New // Complaints clientCloseComplaintSuccess, complaintToModerationForUser, + complaintRegistered, // New + contentUnpublished, // New + contentUnpublishedComplaint, // New // Finance clientRefundSuccess, consultantRefundSuccess, clientRejectSuccess, consultantRejectSuccess, + financeQuestionCreated, // New // Score scoreAdded, @@ -1794,6 +2155,9 @@ enum NotificationType { // Diary diaryReminderForUsers, + // Chat + newChatMessage, // New + // Legacy types (keeping for backward compatibility) planSubscriptionStartedToAuthor, planSubscriptionCanceledToAuthor, @@ -1865,7 +2229,23 @@ 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, + 'content-unpublished' => NotificationType.contentUnpublished, + 'content-unpublished-complaint' => NotificationType.contentUnpublishedComplaint, + 'finance-question-created' => NotificationType.financeQuestionCreated, + 'new-chat-message' => NotificationType.newChatMessage, + 'complete-registration' => NotificationType.completeRegistration, + 'chosen-diet-expired' => NotificationType.chosenDietExpired, + 'motivate-user' => NotificationType.motivateUser, + 'comment-complaint' => NotificationType.commentComplaint, + 'comment-deleted' => NotificationType.commentDeleted, + 'user-subscribed' => NotificationType.userSubscribed, + // 'diary-reminder' => NotificationType.diaryReminder, // Already legacy? Check if it maps to diaryReminderNotification + 'meal-reminder' => NotificationType.mealReminder, // Maps to legacy if string matches? No, legacy was App\\... + // Legacy mapping 'App\\Notifications\\Plan\\PlanSubscriptionStartedToAuthor' => NotificationType.planSubscriptionStartedToAuthor, 'App\\Notifications\\Plan\\PlanSubscriptionCanceledToAuthor' => NotificationType.planSubscriptionCanceledToAuthor, diff --git a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart index 110607c0..54c84507 100644 --- a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart +++ b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart @@ -267,10 +267,82 @@ class NotificationsCubit extends Cubit { 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()); } } diff --git a/lib/features/notifications/presentation/widgets/notification_widget.dart b/lib/features/notifications/presentation/widgets/notification_widget.dart index 0169f96d..1c2d1c58 100644 --- a/lib/features/notifications/presentation/widgets/notification_widget.dart +++ b/lib/features/notifications/presentation/widgets/notification_widget.dart @@ -9,9 +9,194 @@ import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + class NotificationWidget extends StatelessWidget { const NotificationWidget({super.key, required this.notification}); final BaseNotificationModel notification; + + void _onTap(BuildContext context) { + // Mark as read first + context.read().markNotificationsAsRead(); + + switch (notification) { + case CommentReplied n: + _navigateToEntity(context, n.commentedEntityType, n.commentedEntityId, n.commentId); + case EntityCommented n: + _navigateToEntity(context, n.entityType, n.entityId, n.commentId); + case PersonalDietIsDefinedToCustomerNotification n: + context.push(RouteConstants.aboutDietScreen, extra: {'id': n.dietId}); + case PlanSubscriptionStartedToAuthorNotification n: + if (n.chatId.isNotEmpty && n.chatId != '0') { + context.push(RouteConstants.chatScreen, extra: int.tryParse(n.chatId) ?? 0); + } else { + // Fallback if no chat ID + context.push(RouteConstants.profileScreen); + } + case ConsultantAssignedNotification n: + context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); + case StayOnTrackToAuthorNotification n: + context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); + case StayOnTrackToCustomerNotification n: + context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); + case ChosenPlanStartedToClientNotification n: + context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); + case ChosenPlanStartedToConsultantNotification n: + context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); + case ProductStatusUpdatedForAuthorNotification(): + context.push(RouteConstants.profileScreen); + // New Cases with Safety Checks + case NewChatMessageNotification n: + if (n.chatId != 0) context.push(RouteConstants.chatScreen, extra: n.chatId); + case FinanceQuestionCreatedNotification(): + context.push(RouteConstants.userFinanceScreen); + case ChosenDietExpiredNotification(): + context.push(RouteConstants.myDietsScreen); + case ContentUnpublishedNotification(): + context.push(RouteConstants.profileScreen); + case CompleteRegistrationNotification(): + context.push(RouteConstants.profileScreen); // Or edit profile + case ConsultantEducationUpdatedNotification(): + context.push(RouteConstants.profileScreen); + case ComplaintRegisteredNotification(): + context.push(RouteConstants.profileScreen); + case ConsultantRequestStartedNotification(): + // Navigate to where requests are shown + context.push(RouteConstants.profileScreen); + case ClientCloseComplaintSuccessNotification(): + context.push(RouteConstants.profileScreen); + case ClientRefundSuccessNotification(): + context.push(RouteConstants.userFinanceScreen); + case ConsultantRefundSuccessNotification(): + context.push(RouteConstants.userFinanceScreen); + case ClientRejectSuccessNotification(): + context.push(RouteConstants.aboutPlanScreen); // Best guess + case ConsultantRejectSuccessNotification(): + context.push(RouteConstants.aboutPlanScreen); + case ScoreSubstractedNotification(): + context.push(RouteConstants.profileScreen); + case ScoreAdded(): + context.push(RouteConstants.profileScreen); + case DiaryReminderForUsersNotification(): + context.push(RouteConstants.myDietsScreen); // Likely where diary is + case MotivateUserNotification(): + context.push(RouteConstants.myDietsScreen); + case CommentComplaintNotification(): + context.push(RouteConstants.profileScreen); + case CommentDeletedNotification(): + context.push(RouteConstants.profileScreen); + case UserSubscribedNotification(): + context.push(RouteConstants.profileScreen); + case BadgeAwardedNotification(): + context.push(RouteConstants.profileScreen); + case BadgeRevokedNotification(): + context.push(RouteConstants.profileScreen); + case SubscriptionNotification(): + context.push(RouteConstants.userFinanceScreen); + case PhoneChangedNotification(): + context.push(RouteConstants.profileScreen); + case UserDailyReportCompletedNotification(): + // Attempt to go to plan if we had ID, but we don't in this model. Go to plans list. + context.push(RouteConstants.myPlansScreen); + case ClientCancelPlanSubscriptionToSubscriberNotification(): + context.push(RouteConstants.myPlansScreen); + case ClientCancelPlanSubscriptionToAuthorNotification(): + context.push(RouteConstants.myPlansScreen); + case ClientStartPlanSubscriptionToAuthorNotification(): + context.push(RouteConstants.myPlansScreen); + case ClientPaidPlanSubscriptionToSubscriberNotification(): + context.push(RouteConstants.userFinanceScreen); + case ConsultantConfirmPlanSubscriptionToAuthorNotification(): + context.push(RouteConstants.myPlansScreen); + case ConsultantLeftDiaryCommentNotification(): + // Needs planID to go to specific plan, model has planName. + // Optimally we'd go to the diary. For now plans screen. + context.push(RouteConstants.myPlansScreen); + case ClientPaidPlanSubscriptionToAuthorNotification(): + context.push(RouteConstants.userFinanceScreen); + case ConsultantConfirmPlanSubscriptionToSubscriberNotification(): + context.push(RouteConstants.myPlansScreen); + case ClientStartPlanSubscriptionToSubscriberNotification(): + context.push(RouteConstants.myPlansScreen); + case ChosenPlanClientLastDayStartedNotification(): + context.push(RouteConstants.myPlansScreen); + case ChosenPlanClientLastDayFinishedNotification(): + context.push(RouteConstants.myPlansScreen); + case PersonalDietCreationNeededNotification(): + context.push(RouteConstants.myPlansScreen); + case DietCanNotBeStartedNotification(): + context.push(RouteConstants.myDietsScreen); + case DietWillStartedInNotification(): + context.push(RouteConstants.myDietsScreen); + case DietExpiredNotification(): + context.push(RouteConstants.myDietsScreen); + case DietStartedNotification(): + context.push(RouteConstants.myDietsScreen); + case DietCancelledNotification(): + context.push(RouteConstants.myDietsScreen); + case MealReminderNotification(): + context.push(RouteConstants.myDietsScreen); + case WorkoutReminderNotification(): + context.push(RouteConstants.myDietsScreen); + case GoalAchievedNotification(): + context.push(RouteConstants.profileScreen); + case PaymentSuccessfulNotification(): + context.push(RouteConstants.userFinanceScreen); + case PaymentFailedNotification(): + context.push(RouteConstants.userFinanceScreen); + case ConsultantUnassignedNotification n: + context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); + case PlanSubscriptionRenewedNotification(): + context.push(RouteConstants.userFinanceScreen); + case PlanSubscriptionExpiredNotification(): + context.push(RouteConstants.userFinanceScreen); + case ComplaintToModerationForUser(): + context.push(RouteConstants.profileScreen); + case ChosenPlanConsultantLastDayStarted n: + context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); + case ChosenPlanLastDayFinishedForUser(): + context.push(RouteConstants.myPlansScreen); + case ChosenPlanFinishedToConsultantNotification n: + context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); + case PlanSubscriptionPaidToAuthorNotification(): + context.push(RouteConstants.userFinanceScreen); + case ChosenPlanTomorrowReminderToConsultantNotification(): + context.push(RouteConstants.myPlansScreen); + case ChosenPlanTomorrowReminderToClientNotification(): + context.push(RouteConstants.myPlansScreen); + case PlanSubscriptionCanceledToCustomerNotification(): + context.push(RouteConstants.myPlansScreen); + case ChosenPlanFinishedToClientNotification(): + context.push(RouteConstants.myPlansScreen); + case PersonalizedDietIsDefinedNotification(): + context.push(RouteConstants.myDietsScreen); + case ConsultantLeftCommentNotification(): + context.push(RouteConstants.myPlansScreen); + case DiaryReminderNotification(): + context.push(RouteConstants.myDietsScreen); + case PlanSubscriptionCanceledToAuthorNotification(): + context.push(RouteConstants.myPlansScreen); + default: + // Do nothing if no logical destination + break; + } + } + + 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')) { + context.push(RouteConstants.aboutRecipesScreen, extra: {'id': id, 'highlightedCommentId': commentId}); + } else if (type.contains('Plan')) { + context.push(RouteConstants.aboutPlanScreen, extra: {'id': id, 'highlightedCommentId': commentId}); + } else if (type.contains('Diet')) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': id, 'highlightedCommentId': commentId}); + } + } + +// }; +// } @override Widget build(BuildContext context) { return switch (notification) { @@ -117,6 +302,20 @@ class NotificationWidget extends StatelessWidget { PersonalDietIsDefinedToCustomerNotification() => _PersonalDietIsDefinedToCustomerWidget( notification: notification as PersonalDietIsDefinedToCustomerNotification, ), + // New Widgets + ConsultantEducationUpdatedNotification() => _ConsultantEducationUpdatedWidget( + notification: notification as ConsultantEducationUpdatedNotification, + ), + ComplaintRegisteredNotification() => _ComplaintRegisteredWidget(notification: notification as ComplaintRegisteredNotification), + ContentUnpublishedNotification() => _ContentUnpublishedWidget(notification: notification as ContentUnpublishedNotification), + FinanceQuestionCreatedNotification() => _FinanceQuestionCreatedWidget(notification: notification as FinanceQuestionCreatedNotification), + NewChatMessageNotification() => _NewChatMessageWidget(notification: notification as NewChatMessageNotification), + CompleteRegistrationNotification() => _CompleteRegistrationWidget(notification: notification as CompleteRegistrationNotification), + ChosenDietExpiredNotification() => _ChosenDietExpiredWidget(notification: notification as ChosenDietExpiredNotification), + MotivateUserNotification() => _MotivateUserWidget(notification: notification as MotivateUserNotification), + CommentComplaintNotification() => _CommentComplaintWidget(notification: notification as CommentComplaintNotification), + CommentDeletedNotification() => _CommentDeletedWidget(notification: notification as CommentDeletedNotification), + UserSubscribedNotification() => _UserSubscribedWidget(notification: notification as UserSubscribedNotification), UnknownNotification() => _UnknownNotificationWidget(notification: notification as UnknownNotification), }; } @@ -131,8 +330,9 @@ class _PlanSubscriptionStartedToAuthorWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.userSubscribedToPlan(notification.customerName, notification.planName), + notificationsText: '${s.userSubscribedToPlan(notification.customerName, notification.planName)}! 🎉', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -146,8 +346,9 @@ class _PlanSubscriptionCanceledToAuthorWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.userCanceldThePlanSubscription(notification.customerName, notification.planName), + notificationsText: 'Отмена подписки. Клиент ${notification.customerName} отказался от плана "${notification.planName}"', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -159,7 +360,12 @@ class _ChosenPlanStartedToClientWidget extends StatelessWidget { Widget build(BuildContext context) { final s = S.of(context); - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: s.planStarted(notification.planName), createdAt: notification.createdAt); + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Ваш план "${notification.planName}" начался! Удачи! 🚀', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); } } @@ -172,8 +378,9 @@ class _ChosenPlanTomorrowReminderToClientWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.tomorrowYourPlanWillStart(notification.planName), + notificationsText: 'Напоминание: Завтра старт плана "${notification.planName}"', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -184,7 +391,12 @@ class _DiaryReminderWidget extends StatelessWidget { @override Widget build(BuildContext context) { final s = S.of(context); - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: s.fillYasterday, createdAt: notification.createdAt); + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Не забудьте заполнить дневник за вчера! 📝', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); } } @@ -197,8 +409,9 @@ class _ConsultantLeftCommentWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.consultantLeftComment(notification.date.format('dd.MM.yyyy')), + notificationsText: 'Новый комментарий от консультанта (за ${notification.date.format('dd.MM.yyyy')})', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -208,7 +421,12 @@ class _PersonalizedDietIsDefinedWidget extends StatelessWidget { final PersonalizedDietIsDefinedNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Персональная диета определена', createdAt: notification.createdAt); + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Персональная диета определена', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); } } @@ -221,8 +439,9 @@ class _ChosenPlanFinishedToClientWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: '${s.congratsYouHaveFinishedPlan} «${notification.planName}»', + notificationsText: 'Поздравляем! План «${notification.planName}» успешно завершен! 🏆', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -236,8 +455,9 @@ class _PlanSubscriptionCanceledToCustomerWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.youCanceledSubscriptionToPlan(notification.planName), + notificationsText: 'Подписка на план "${notification.planName}" отменена.', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -251,8 +471,9 @@ class _ChosenPlanStartedToConsultantWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.planWithUserStarted(notification.planName, notification.customerName), + notificationsText: 'Начало работы: План "${notification.planName}" с клиентом ${notification.customerName}', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -266,8 +487,9 @@ class _ChosenPlanTomorrowReminderToConsultantWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.tommorowPlanWithUserWillStart(notification.planName, notification.customerName), + notificationsText: 'Напоминание: Завтра старт работы с ${notification.customerName} по плану "${notification.planName}"', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -281,8 +503,9 @@ class _PlanSubscriptionPaidToAuthorWidget extends StatelessWidget { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: s.userPaidThePlan(notification.customerName, notification.planName), + notificationsText: 'Оплата получена! Клиент ${notification.customerName} оплатил план "${notification.planName}" 💰', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -293,16 +516,19 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget { @override Widget build(BuildContext context) { final s = S.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + return GestureDetector( + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + behavior: HitTestBehavior.opaque, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('${s.congratsYouHaveFinishedPlan} «${notification.planName}»', style: TextStyles.textMSemibold.copyWith(color: appTheme.black)), + Text('План «${notification.planName}» завершен! Поздравляем! 🎉', style: TextStyles.textMSemibold.copyWith(color: appTheme.black)), const SizedBox(height: 12), Text(s.userConfirmedPlanCompletionWithoutSum, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), const SizedBox(height: 12), @@ -331,6 +557,7 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget { style: TextStyles.textSMedium.copyWith(color: appTheme.gray600), ), ], + ), ); } @@ -377,6 +604,7 @@ class _BaseNotificationWidget extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: onTap, + behavior: HitTestBehavior.opaque, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -385,8 +613,14 @@ class _BaseNotificationWidget extends StatelessWidget { Expanded( child: Text(notificationsText, style: TextStyles.textMMedium.copyWith(color: appTheme.black)), ), + if (onTap != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon(Icons.chevron_right, color: appTheme.gray500, size: 20), + ), if (!isRead) Container( + margin: const EdgeInsets.only(left: 8), decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), width: 8, height: 8, @@ -440,7 +674,7 @@ class _ChosenPlanLastDayFinishedForUserWidget extends StatelessWidget { final ChosenPlanLastDayFinishedForUser notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Последний день плана завершен', createdAt: notification.createdAt); + return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Финишная прямая! Последний день плана завершен.', createdAt: notification.createdAt); } } @@ -451,8 +685,9 @@ class _ChosenPlanConsultantLastDayStartedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Последний день плана начался: ${notification.planName}', + notificationsText: 'Внимание: Начался последний день плана "${notification.planName}"', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -462,7 +697,12 @@ class _ComplaintToModerationForUserWidget extends StatelessWidget { final ComplaintToModerationForUser notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Жалоба отправлена на модерацию', createdAt: notification.createdAt); + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Ваша жалоба принята и находится на модерации.', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); } } @@ -473,8 +713,9 @@ class _CommentRepliedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Ответ на комментарий: ${notification.entityName}', + notificationsText: 'Вам ответили в комментариях к: ${notification.entityName}', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -486,8 +727,9 @@ class _ScoreAddedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Добавлены баллы: ${notification.scoreValue}', + notificationsText: 'Поздравляем! Вам начислены баллы: ${notification.scoreValue} ⭐', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -499,8 +741,9 @@ class _EntityCommentedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Комментарий к ${notification.entityTranslatedType}: ${notification.entityName}', + notificationsText: 'Новый комментарий к вашей публикации "${notification.entityTranslatedType}": ${notification.entityName}', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -512,8 +755,9 @@ class _UnknownNotificationWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Неизвестное уведомление: ${notification.type.toString()}', + notificationsText: 'У вас новое уведомление! Пожалуйста, обновите приложение, чтобы увидеть детали.', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -526,8 +770,9 @@ class _PlanSubscriptionExpiredWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Подписка на план "${notification.planName}" истекла', + notificationsText: 'Срок действия подписки на план "${notification.planName}" истек. Продлите, чтобы продолжить! ⏳', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -539,8 +784,9 @@ class _PlanSubscriptionRenewedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Подписка на план "${notification.planName}" продлена до ${notification.renewalDate}', + notificationsText: 'Успешное продление! Подписка на план "${notification.planName}" активна до ${notification.renewalDate} ✅', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -552,8 +798,9 @@ class _ConsultantAssignedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'К плану "${notification.planName}" назначен консультант ${notification.consultantName}', + notificationsText: 'Вам назначен консультант ${notification.consultantName} по плану "${notification.planName}". Приветствуйте! 👋', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -565,8 +812,9 @@ class _ConsultantUnassignedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Консультант ${notification.consultantName} отстранен от плана "${notification.planName}"', + notificationsText: 'Изменение в плане "${notification.planName}": консультант ${notification.consultantName} отстранен.', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -578,8 +826,9 @@ class _PaymentFailedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Ошибка оплаты плана "${notification.planName}": ${notification.errorMessage}', + notificationsText: 'Не удалось провести оплату за план "${notification.planName}". Причина: ${notification.errorMessage} ❌', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -591,8 +840,9 @@ class _PaymentSuccessfulWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Успешная оплата плана "${notification.planName}" на сумму ${notification.amount} ${notification.currency}', + notificationsText: 'Оплата прошла успешно! План "${notification.planName}" оплачен на сумму ${notification.amount} ${notification.currency} 💳', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -604,8 +854,9 @@ class _GoalAchievedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Достигнута цель "${notification.goalName}": ${notification.achievedValue}/${notification.targetValue}', + notificationsText: 'Цель достигнута! "${notification.goalName}": ${notification.achievedValue}/${notification.targetValue} 🎯', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -617,8 +868,9 @@ class _WorkoutReminderWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Напоминание о тренировке "${notification.workoutName}" (${notification.duration} мин)', + notificationsText: 'Время тренировки! "${notification.workoutName}" ждет вас (${notification.duration} мин) 💪', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -630,8 +882,9 @@ class _MealReminderWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Напоминание о приеме пищи "${notification.mealName}" (${notification.calories} ккал)', + notificationsText: 'Пора подкрепиться! "${notification.mealName}" (${notification.calories} ккал) 🍎', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -644,8 +897,9 @@ class _DietCancelledWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Диета "${notification.dietName}" отменена', + notificationsText: 'Диета "${notification.dietName}" была отменена.', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -657,8 +911,9 @@ class _DietStartedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Диета "${notification.dietName}" началась', + notificationsText: 'Старт диеты "${notification.dietName}"! Успехов! 🥗', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -670,8 +925,9 @@ class _DietExpiredWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Диета "${notification.dietName}" истекла', + notificationsText: 'Срок действия диеты "${notification.dietName}" подошел к концу.', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -683,8 +939,9 @@ class _DietWillStartedInWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Диета "${notification.dietName}" начнется ${notification.startDate}', + notificationsText: 'Скоро старт! Диета "${notification.dietName}" начнется ${notification.startDate} ⏰', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -696,8 +953,9 @@ class _DietCanNotBeStartedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Диета "${notification.dietName}" не может быть начата: ${notification.reason}', + notificationsText: 'Внимание: Не удалось начать диету "${notification.dietName}". Причина: ${notification.reason}', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -709,8 +967,9 @@ class _PersonalDietCreationNeededWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Требуется создание персональной диеты для клиента ${notification.clientName} по плану "${notification.planName}"', + notificationsText: 'Задача: Создать персональную диету для клиента ${notification.clientName} (План "${notification.planName}")', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -722,8 +981,9 @@ class _ChosenPlanClientLastDayFinishedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Последний день плана "${notification.planName}" завершен', + notificationsText: 'Поздравляем с завершением плана "${notification.planName}"! 🎉', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -735,8 +995,9 @@ class _ChosenPlanClientLastDayStartedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Последний день плана "${notification.planName}" начался', + notificationsText: 'Сегодня последний день плана "${notification.planName}"! Финиш близко! 🏁', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -748,8 +1009,9 @@ class _StayOnTrackToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Клиент ${notification.clientName} остается на плане "${notification.planName}"', + notificationsText: 'Клиент ${notification.clientName} продолжает уверенно идти по плану "${notification.planName}" 🔥', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -761,8 +1023,9 @@ class _ClientStartPlanSubscriptionToSubscriberWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Подписка на план "${notification.planName}" началась', + notificationsText: 'У вас новая подписка на план "${notification.planName}"! 🌟', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -774,8 +1037,9 @@ class _ConsultantConfirmPlanSubscriptionToSubscriberWidget extends StatelessWidg Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Консультант ${notification.consultantName} подтвердил подписку на план "${notification.planName}"', + notificationsText: 'Ваша подписка на план "${notification.planName}" подтверждена консультантом ${notification.consultantName} ✅', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -787,8 +1051,9 @@ class _ClientPaidPlanSubscriptionToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Клиент ${notification.clientName} оплатил подписку на план "${notification.planName}" на сумму ${notification.amount}', + notificationsText: 'Поступление средств: Клиент ${notification.clientName} оплатил подписку на план "${notification.planName}" (${notification.amount}) 💸', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -800,8 +1065,9 @@ class _ConsultantLeftDiaryCommentWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Консультант ${notification.consultantName} оставил комментарий к дневнику по плану "${notification.planName}"', + notificationsText: 'Ваш консультант ${notification.consultantName} прокомментировал дневник по плану "${notification.planName}" 💬', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -813,8 +1079,9 @@ class _ConsultantConfirmPlanSubscriptionToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Консультант ${notification.consultantName} подтвердил подписку на план "${notification.planName}"', + notificationsText: 'Консультант ${notification.consultantName} подтвердил подписку на план "${notification.planName}" 🤝', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -826,8 +1093,9 @@ class _ClientPaidPlanSubscriptionToSubscriberWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Оплачена подписка на план "${notification.planName}" на сумму ${notification.amount}', + notificationsText: 'Оплата подписки на план "${notification.planName}" прошла успешно (${notification.amount}) ✅', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -839,8 +1107,9 @@ class _ClientStartPlanSubscriptionToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Клиент ${notification.clientName} начал подписку на план "${notification.planName}"', + notificationsText: 'Новый подписчик! Клиент ${notification.clientName} начал подписку на план "${notification.planName}" 🎈', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -852,8 +1121,9 @@ class _ClientCancelPlanSubscriptionToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Клиент ${notification.clientName} отменил подписку на план "${notification.planName}"', + notificationsText: 'Отмена подписки: Клиент ${notification.clientName} отказался от плана "${notification.planName}"', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -865,8 +1135,9 @@ class _ClientCancelPlanSubscriptionToSubscriberWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Подписка на план "${notification.planName}" отменена', + notificationsText: 'Ваша подписка на план "${notification.planName}" была отменена.', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -878,8 +1149,9 @@ class _UserDailyReportCompletedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Клиент ${notification.clientName} завершил ежедневный отчет по плану "${notification.planName}"', + notificationsText: 'Отчет получен: Клиент ${notification.clientName} заполнил дневник по плану "${notification.planName}" 📝', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -891,8 +1163,9 @@ class _StayOnTrackToCustomerWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Консультант ${notification.consultantName} остается на плане "${notification.planName}"', + notificationsText: 'Консультант ${notification.consultantName} продолжает работу с вами по плану "${notification.planName}" 💪', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -904,8 +1177,9 @@ class _PhoneChangedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Номер телефона изменен с ${notification.oldPhone} на ${notification.newPhone}', + notificationsText: 'Ваш номер телефона был успешно изменен на ${notification.newPhone} 📱', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -917,8 +1191,9 @@ class _SubscriptionWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Подписка ${notification.subscriptionType} на план "${notification.planName}"', + notificationsText: 'Оформлена подписка ${notification.subscriptionType} на план "${notification.planName}" 🎉', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -930,8 +1205,9 @@ class _BadgeAwardedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Получен значок "${notification.badgeName}": ${notification.badgeDescription}', + notificationsText: 'Новая награда! Вы получили значок "${notification.badgeName}": ${notification.badgeDescription} 🏅', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -943,8 +1219,9 @@ class _BadgeRevokedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Отозван значок "${notification.badgeName}": ${notification.reason}', + notificationsText: 'Значок "${notification.badgeName}" был отозван. Причина: ${notification.reason}', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -956,8 +1233,9 @@ class _ConsultantRequestStartedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Запрос к консультанту ${notification.consultantName} начат', + notificationsText: 'Запрос отправлен консультанту ${notification.consultantName} 📩', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -969,8 +1247,9 @@ class _ClientCloseComplaintSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Жалоба на ${notification.complaintType} "${notification.entityName}" успешно закрыта', + notificationsText: 'Ваша жалоба на ${notification.complaintType} "${notification.entityName}" была рассмотрена и закрыта.', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -982,8 +1261,9 @@ class _ClientRefundSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Возврат средств по плану "${notification.planName}" на сумму ${notification.amount}: ${notification.reason}', + notificationsText: 'Вам одобрен возврат средств по плану "${notification.planName}" (${notification.amount}). Причина: ${notification.reason}', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -995,8 +1275,9 @@ class _ConsultantRefundSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Возврат средств клиенту ${notification.clientName} по плану "${notification.planName}" на сумму ${notification.amount}', + notificationsText: 'Выполнен возврат средств клиенту ${notification.clientName} по плану "${notification.planName}" (${notification.amount})', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1008,8 +1289,9 @@ class _ClientRejectSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Отклонение по плану "${notification.planName}": ${notification.reason}', + notificationsText: 'Отказ по плану "${notification.planName}" принят: ${notification.reason}', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1021,8 +1303,9 @@ class _ConsultantRejectSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Отклонение клиентом ${notification.clientName} по плану "${notification.planName}": ${notification.reason}', + notificationsText: 'Клиент ${notification.clientName} отказался от плана "${notification.planName}": ${notification.reason}', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1034,8 +1317,9 @@ class _ScoreSubstractedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Вычтено ${notification.scoreValue} баллов за ${notification.scorableName}', + notificationsText: 'Списание баллов: -${notification.scoreValue} за ${notification.scorableName}', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1045,10 +1329,22 @@ class _ProductStatusUpdatedForAuthorWidget extends StatelessWidget { final ProductStatusUpdatedForAuthorNotification notification; @override Widget build(BuildContext context) { + String text; + if (notification.oldStatus.isNotEmpty && notification.newStatus.isNotEmpty) { + text = 'Статус продукта "${notification.productName}" изменен с ${notification.oldStatus} на ${notification.newStatus}'; + } else if (notification.newStatus.isNotEmpty) { + text = 'Статус продукта "${notification.productName}" изменен на ${notification.newStatus}'; + } else if (notification.status.isNotEmpty) { + text = 'Статус продукта "${notification.productName}": ${notification.status}'; + } else { + text = 'Статус продукта "${notification.productName}" обновлен'; + } + return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Статус продукта "${notification.productName}" изменен с ${notification.oldStatus} на ${notification.newStatus}', + notificationsText: text, createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1058,7 +1354,12 @@ class _DiaryReminderForUsersWidget extends StatelessWidget { final DiaryReminderForUsersNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Напоминание о заполнении дневника', createdAt: notification.createdAt); + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Не забудьте заполнить дневник сегодня! 📓', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); } } @@ -1069,9 +1370,163 @@ class _PersonalDietIsDefinedToCustomerWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - onTap: () => GoRouter.of(context).push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId, 'navigateToComments': false}), - notificationsText: 'Персональная диета "${notification.dietName}" установлена', + notificationsText: 'Для вас составлена персональная диета "${notification.dietName}"! 🥗', createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); + } +} + +class _ConsultantEducationUpdatedWidget extends StatelessWidget { + const _ConsultantEducationUpdatedWidget({required this.notification}); + final ConsultantEducationUpdatedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Консультант ${notification.consultantName} обновил данные об образовании.', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); + } +} + +class _ComplaintRegisteredWidget extends StatelessWidget { + const _ComplaintRegisteredWidget({required this.notification}); + final ComplaintRegisteredNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Мы получили вашу жалобу на ${notification.entityName}. Спасибо за сигнал!', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); + } +} + +class _ContentUnpublishedWidget extends StatelessWidget { + const _ContentUnpublishedWidget({required this.notification}); + final ContentUnpublishedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Ваша публикация ${notification.entityName} была скрыта. Причина: ${notification.reason}', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); + } +} + +class _FinanceQuestionCreatedWidget extends StatelessWidget { + const _FinanceQuestionCreatedWidget({required this.notification}); + final FinanceQuestionCreatedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Служба поддержки: Новый вопрос по финансам: ${notification.questionText}', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); + } +} + +class _NewChatMessageWidget extends StatelessWidget { + const _NewChatMessageWidget({required this.notification}); + final NewChatMessageNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Входящее сообщение от ${notification.senderName}: "${notification.messagePreview}"', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); + } +} + +class _CompleteRegistrationWidget extends StatelessWidget { + const _CompleteRegistrationWidget({required this.notification}); + final CompleteRegistrationNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Завершите регистрацию, чтобы получить доступ ко всем функциям! 🚀', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); + } +} + +class _ChosenDietExpiredWidget extends StatelessWidget { + const _ChosenDietExpiredWidget({required this.notification}); + final ChosenDietExpiredNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Диета "${notification.dietName}" завершена. Время подвести итоги!', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); + } +} + +class _MotivateUserWidget extends StatelessWidget { + const _MotivateUserWidget({required this.notification}); + final MotivateUserNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: notification.message, + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); + } +} + +class _CommentComplaintWidget extends StatelessWidget { + const _CommentComplaintWidget({required this.notification}); + final CommentComplaintNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Поступила жалоба на ваш комментарий: "${notification.commentPreview}". Причина: ${notification.reason}', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); + } +} + +class _CommentDeletedWidget extends StatelessWidget { + const _CommentDeletedWidget({required this.notification}); + final CommentDeletedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Ваш комментарий был удален по правилам сообщества: "${notification.commentPreview}"', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + ); + } +} + +class _UserSubscribedWidget extends StatelessWidget { + const _UserSubscribedWidget({required this.notification}); + final UserSubscribedNotification notification; + @override + Widget build(BuildContext context) { + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'У вас новый подписчик: ${notification.subscriberName}! 👋', + createdAt: notification.createdAt, + onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } diff --git a/lib/features/notifications/service/notifications_service.dart b/lib/features/notifications/service/notifications_service.dart index 1fd5e4d6..c934fb53 100644 --- a/lib/features/notifications/service/notifications_service.dart +++ b/lib/features/notifications/service/notifications_service.dart @@ -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) { diff --git a/lib/features/plans/presentation/pages/about_plan_screen.dart b/lib/features/plans/presentation/pages/about_plan_screen.dart index 03e94814..283ef0fc 100644 --- a/lib/features/plans/presentation/pages/about_plan_screen.dart +++ b/lib/features/plans/presentation/pages/about_plan_screen.dart @@ -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 createState() => _AboutPlanScreenState(); } @@ -468,13 +476,14 @@ class _AboutPlanScreenState extends State { ), 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, + ), ], ), ), diff --git a/lib/features/recipe/presentation/pages/about_recipe_screen.dart b/lib/features/recipe/presentation/pages/about_recipe_screen.dart index a5448a86..ff9e4f0a 100644 --- a/lib/features/recipe/presentation/pages/about_recipe_screen.dart +++ b/lib/features/recipe/presentation/pages/about_recipe_screen.dart @@ -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 createState() => _AboutRecipeScreenState(); @@ -391,6 +392,7 @@ class _AboutRecipeScreenState extends State { commentSourceType: CommentSourceType.recipe, comment: state.comment, replyId: replyId, + highlightedCommentId: widget.highlightedCommentId, ), ], ), diff --git a/test/features/diets/presentation/pages/about_diet_screen_smoke_test.dart b/test/features/diets/presentation/pages/about_diet_screen_smoke_test.dart index a09bfe1d..18546734 100644 --- a/test/features/diets/presentation/pages/about_diet_screen_smoke_test.dart +++ b/test/features/diets/presentation/pages/about_diet_screen_smoke_test.dart @@ -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); diff --git a/test/features/notifications/models/notification_model_test.dart b/test/features/notifications/models/notification_model_test.dart index 28b21566..cb7504ec 100644 --- a/test/features/notifications/models/notification_model_test.dart +++ b/test/features/notifications/models/notification_model_test.dart @@ -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); diff --git a/test/features/notifications/presentation/widgets/notification_widget_test.dart b/test/features/notifications/presentation/widgets/notification_widget_test.dart index f624993a..d6a8025d 100644 --- a/test/features/notifications/presentation/widgets/notification_widget_test.dart +++ b/test/features/notifications/presentation/widgets/notification_widget_test.dart @@ -160,6 +160,8 @@ void main() { type: NotificationType.chosenPlanStartedToConsultant, planName: 'Test Plan', customerName: 'Test Customer', + planId: 1, + customerId: 1, isRead: false, createdAt: testDate, ); @@ -586,6 +588,8 @@ void main() { type: NotificationType.stayOnTrackToAuthor, planName: 'Test Plan', clientName: 'Test Client', + planId: 1, + clientId: 1, isRead: false, createdAt: testDate, ); @@ -752,6 +756,8 @@ void main() { type: NotificationType.stayOnTrackToCustomer, planName: 'Test Plan', consultantName: 'Test Consultant', + planId: 1, + consultantId: 1, isRead: false, createdAt: testDate, ); diff --git a/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart b/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart index 32f67e95..4fa46bb3 100644 --- a/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart +++ b/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart @@ -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); From c540cbe6dfe53fff9b672191e12e4b6bddc56d7a Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 18 Feb 2026 19:45:32 +0300 Subject: [PATCH 2/9] feat(notifications): added navigation from different types of notifications --- lib/core/routing/main_navigation_routes.dart | 35 +- lib/core/routing/other_routes.dart | 5 +- .../widgets/bottom_nav_scope.dart | 38 + .../widgets/bottom_navigation_bar_widget.dart | 25 + .../chat/presentation/pages/chats_screen.dart | 15 +- .../widgets/consultation_card_widget.dart | 6 +- .../diary/presentation/bloc/diary_cubit.dart | 8 +- .../presentation/pages/diary_screen.dart | 24 +- .../presentation/widgets/calendar_widget.dart | 20 +- .../models/notification_model.dart | 688 ++++++++++++++---- .../widgets/notification_widget.dart | 597 +++++++-------- .../bloc/sub_cubit/sub_cubit.dart | 18 +- .../presentation/pages/subs_screen.dart | 2 +- 13 files changed, 974 insertions(+), 507 deletions(-) create mode 100644 lib/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart diff --git a/lib/core/routing/main_navigation_routes.dart b/lib/core/routing/main_navigation_routes.dart index 88a8db04..f7756fbd 100644 --- a/lib/core/routing/main_navigation_routes.dart +++ b/lib/core/routing/main_navigation_routes.dart @@ -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,8 @@ class MainNavigationRoutes { profileAvatarCubit.getMyProfile(); }); } - - return BottomNavigationBarWidget(child: child); + + return _MainShellWithNavScope(child: child); }, routes: [ GoRoute( @@ -58,7 +59,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 +69,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 +98,26 @@ 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}); + + final Widget child; + + @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(child: widget.child), + ); + } +} diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 0e30336e..a04d2bee 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -195,7 +195,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 @@ -276,8 +276,7 @@ class OtherRoutes { builder: (context, state) { final data = state.extra as Map; final diaryId = data['diaryId'] as int; - final currentPlanDay = data['currentDiaryDay'] as int; - return UserDiaryScreen(diaryId: diaryId, currentPlanDay: currentPlanDay); + return UserDiaryScreen(diaryId: diaryId); }, ), GoRoute( diff --git a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart new file mode 100644 index 00000000..53ea8741 --- /dev/null +++ b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart @@ -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()?.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; +} diff --git a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart index efac96bf..cd534644 100644 --- a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart +++ b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart @@ -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'; @@ -66,6 +67,24 @@ class _BottomNavigationBarWidgetState extends State { } } + /// 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 +140,12 @@ class _BottomNavigationBarWidgetState extends State { // Инициализируем 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); + } + return Scaffold( body: widget.child, bottomNavigationBar: BottomNavigationBar( diff --git a/lib/features/chat/presentation/pages/chats_screen.dart b/lib/features/chat/presentation/pages/chats_screen.dart index 539e971c..993bcfd3 100644 --- a/lib/features/chat/presentation/pages/chats_screen.dart +++ b/lib/features/chat/presentation/pages/chats_screen.dart @@ -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 createState() => _ChatsScreenState(); @@ -23,12 +27,17 @@ class _ChatsScreenState extends State { @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(); 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); } }); } diff --git a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart index b1529280..7efa25f7 100644 --- a/lib/features/consultations/presentation/widgets/consultation_card_widget.dart +++ b/lib/features/consultations/presentation/widgets/consultation_card_widget.dart @@ -172,8 +172,8 @@ class _ConsultationCardWidgetState extends State { ); 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 { .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( diff --git a/lib/features/diary/presentation/bloc/diary_cubit.dart b/lib/features/diary/presentation/bloc/diary_cubit.dart index 0bbb4f82..93e24045 100644 --- a/lib/features/diary/presentation/bloc/diary_cubit.dart +++ b/lib/features/diary/presentation/bloc/diary_cubit.dart @@ -48,6 +48,11 @@ class DiaryCubit extends Cubit { String comment = ''; + void setInitialDate(DateTime? date) { + if (date == null) return; + selectedDay = date; + } + Future setSelectedDay(DateTime day) async { final prevDay = selectedDay; final prevMeal = selectedMeal; @@ -63,7 +68,7 @@ class DiaryCubit extends Cubit { return canSelect; } - Future fetchDiaryV2({bool needLoading = false, int? diaryId}) async { + Future fetchDiaryV2({bool needLoading = false, int? diaryId, DateTime? date}) async { final currentState = state; try { if (needLoading) { @@ -77,6 +82,7 @@ class DiaryCubit extends Cubit { return true; } } + final diaryDay = await getDiary(); if (diaryDay == null) { emit(const DiaryState.error(errorMessage: 'Diary not found')); diff --git a/lib/features/diary/presentation/pages/diary_screen.dart b/lib/features/diary/presentation/pages/diary_screen.dart index c7f832d4..07042f16 100644 --- a/lib/features/diary/presentation/pages/diary_screen.dart +++ b/lib/features/diary/presentation/pages/diary_screen.dart @@ -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 createState() => _DiaryScreenContentState(); } @@ -61,7 +59,11 @@ class _DiaryScreenContentState extends State { final s = S.of(context); return MultiBlocProvider( providers: [ - BlocProvider(create: (BuildContext context) => sl()..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId)), + BlocProvider( + create: (BuildContext context) => sl() + ..setInitialDate(widget.date) + ..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId), + ), BlocProvider(create: (context) => sl()), ], child: BlocBuilder( @@ -91,7 +93,7 @@ class _DiaryScreenContentState extends State { 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( diff --git a/lib/features/diary/presentation/widgets/calendar_widget.dart b/lib/features/diary/presentation/widgets/calendar_widget.dart index cce80a55..bcf1ab09 100644 --- a/lib/features/diary/presentation/widgets/calendar_widget.dart +++ b/lib/features/diary/presentation/widgets/calendar_widget.dart @@ -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 createState() => _CalendarWidgetState(); @@ -18,6 +19,23 @@ class _CalendarWidgetState extends State { 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) { diff --git a/lib/features/notifications/models/notification_model.dart b/lib/features/notifications/models/notification_model.dart index 3375a312..5715fb90 100644 --- a/lib/features/notifications/models/notification_model.dart +++ b/lib/features/notifications/models/notification_model.dart @@ -10,16 +10,24 @@ class PlanSubscriptionStartedToAuthorNotification implements BaseNotificationMod @override final bool isRead; final String planName; + final int planId; final String customerName; + final int userId; + final String userPhotoPath; final String chatId; + final String chatUrl; @override final DateTime createdAt; PlanSubscriptionStartedToAuthorNotification({ required this.type, required this.planName, + required this.planId, required this.customerName, + required this.userId, + required this.userPhotoPath, required this.chatId, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -27,9 +35,13 @@ class PlanSubscriptionStartedToAuthorNotification implements BaseNotificationMod factory PlanSubscriptionStartedToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return PlanSubscriptionStartedToAuthorNotification( type: type, - planName: json['plan_name'], - customerName: json['user_name'], - chatId: json['chat_id'], + planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + customerName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatId: json['chat_id']?.toString() ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -42,14 +54,22 @@ class PlanSubscriptionCanceledToAuthorNotification implements BaseNotificationMo @override final bool isRead; final String planName; + final int planId; final String customerName; + final int userId; + final String userPhotoPath; + final String chatUrl; @override final DateTime createdAt; PlanSubscriptionCanceledToAuthorNotification({ required this.type, required this.planName, + required this.planId, required this.customerName, + required this.userId, + required this.userPhotoPath, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -57,8 +77,12 @@ class PlanSubscriptionCanceledToAuthorNotification implements BaseNotificationMo factory PlanSubscriptionCanceledToAuthorNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return PlanSubscriptionCanceledToAuthorNotification( type: type, - planName: json['plan_name'], - customerName: json['user_name'], + planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + customerName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -72,16 +96,28 @@ class ChosenPlanStartedToClientNotification implements BaseNotificationModel { final bool isRead; final String planName; final int planId; + final String consultantName; + final String peerProfileUrl; @override final DateTime createdAt; - ChosenPlanStartedToClientNotification({required this.type, required this.planName, required this.planId, required this.isRead, required this.createdAt}); + ChosenPlanStartedToClientNotification({ + required this.type, + required this.planName, + required this.planId, + required this.consultantName, + required this.peerProfileUrl, + required this.isRead, + required this.createdAt, + }); factory ChosenPlanStartedToClientNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanStartedToClientNotification( type: type, - planName: json['plan_name'], - planId: json['plan_id'], + planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + consultantName: json['consultant_name'] ?? '', + peerProfileUrl: json['peer_profile_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -94,15 +130,26 @@ class ChosenPlanTomorrowReminderToClientNotification implements BaseNotification @override final bool isRead; final String planName; + final String consultantName; + final int chatId; @override final DateTime createdAt; - ChosenPlanTomorrowReminderToClientNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + ChosenPlanTomorrowReminderToClientNotification({ + required this.type, + required this.planName, + required this.consultantName, + required this.chatId, + required this.isRead, + required this.createdAt, + }); factory ChosenPlanTomorrowReminderToClientNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanTomorrowReminderToClientNotification( type: type, - planName: json['plan_name'], + planName: json['plan_name'] ?? '', + consultantName: json['consultant_name'] ?? '', + chatId: json['chat_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -114,15 +161,25 @@ class DiaryReminderNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + final int? diaryId; + final String? message; @override final DateTime createdAt; - DiaryReminderNotification({required this.type, required this.isRead, required this.createdAt}); + DiaryReminderNotification({ + required this.type, + required this.isRead, + this.diaryId, + this.message, + required this.createdAt, + }); factory DiaryReminderNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return DiaryReminderNotification( type: type, isRead: isRead ?? false, + diaryId: json['diary_id'], + message: json['message'], createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } @@ -133,16 +190,36 @@ class ConsultantLeftCommentNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + final int chosenPlanId; + final int diaryId; final DateTime date; + final String planName; + final String userName; + final String consultantName; @override final DateTime createdAt; - ConsultantLeftCommentNotification({required this.type, required this.date, required this.isRead, required this.createdAt}); + ConsultantLeftCommentNotification({ + required this.type, + required this.chosenPlanId, + required this.diaryId, + required this.date, + required this.planName, + required this.userName, + required this.consultantName, + required this.isRead, + required this.createdAt, + }); factory ConsultantLeftCommentNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ConsultantLeftCommentNotification( type: type, + chosenPlanId: json['chosen_plan_id'] ?? 0, + diaryId: json['diary_id'] ?? 0, date: parseDate(json['diary_date'] ?? DateTime.now().toIso8601String()), + planName: json['plan_name'] ?? '', + userName: json['user_name'] ?? '', + consultantName: json['consultant_name'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -174,14 +251,34 @@ class ChosenPlanFinishedToClientNotification implements BaseNotificationModel { @override final bool isRead; final String planName; + final int chosenPlanId; + final String consultantName; + final String consultantPhotoPath; + final String consultantProfileUrl; + final int consultantId; @override final DateTime createdAt; - ChosenPlanFinishedToClientNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + ChosenPlanFinishedToClientNotification({ + required this.type, + required this.planName, + required this.chosenPlanId, + required this.consultantName, + required this.consultantPhotoPath, + required this.consultantProfileUrl, + required this.consultantId, + required this.isRead, + required this.createdAt, + }); factory ChosenPlanFinishedToClientNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanFinishedToClientNotification( type: type, - planName: json['plan_name'], + planName: json['plan_name'] ?? '', + chosenPlanId: json['chosen_plan_id'] ?? 0, + consultantName: json['consultant_name'] ?? '', + consultantPhotoPath: json['consultant_photo_path'] ?? '', + consultantProfileUrl: json['consultant_profile_url'] ?? '', + consultantId: json['consultant_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -194,14 +291,34 @@ class PlanSubscriptionCanceledToCustomerNotification implements BaseNotification @override final bool isRead; final String planName; + final int planId; + final String authorName; + final int authorId; + final String authorPhotoPath; + final String chatUrl; @override final DateTime createdAt; - PlanSubscriptionCanceledToCustomerNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + PlanSubscriptionCanceledToCustomerNotification({ + required this.type, + required this.planName, + required this.planId, + required this.authorName, + required this.authorId, + required this.authorPhotoPath, + required this.chatUrl, + required this.isRead, + required this.createdAt, + }); factory PlanSubscriptionCanceledToCustomerNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return PlanSubscriptionCanceledToCustomerNotification( type: type, - planName: json['plan_name'], + planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + authorName: json['author_name'] ?? '', + authorId: json['author_id'] ?? 0, + authorPhotoPath: json['author_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -217,6 +334,8 @@ class ChosenPlanStartedToConsultantNotification implements BaseNotificationModel final String customerName; final int planId; final int customerId; + final String peerProfileUrl; + final String consultationUrl; @override final DateTime createdAt; ChosenPlanStartedToConsultantNotification({ @@ -226,6 +345,8 @@ class ChosenPlanStartedToConsultantNotification implements BaseNotificationModel required this.customerName, required this.planId, required this.customerId, + required this.peerProfileUrl, + required this.consultationUrl, required this.createdAt, }); @@ -233,10 +354,12 @@ class ChosenPlanStartedToConsultantNotification implements BaseNotificationModel return ChosenPlanStartedToConsultantNotification( type: type, isRead: isRead ?? false, - planName: json['plan_name'], - customerName: json['client_name'], + planName: json['plan_name'] ?? '', + customerName: json['client_name'] ?? '', planId: json['plan_id'] ?? 0, customerId: json['client_id'] ?? 0, + peerProfileUrl: json['peer_profile_url'] ?? '', + consultationUrl: json['consultation_url'] ?? '', createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } @@ -249,12 +372,14 @@ class ChosenPlanTomorrowReminderToConsultantNotification implements BaseNotifica final bool isRead; final String planName; final String customerName; + final int chatId; @override final DateTime createdAt; ChosenPlanTomorrowReminderToConsultantNotification({ required this.type, required this.planName, required this.customerName, + required this.chatId, required this.isRead, required this.createdAt, }); @@ -262,8 +387,9 @@ class ChosenPlanTomorrowReminderToConsultantNotification implements BaseNotifica factory ChosenPlanTomorrowReminderToConsultantNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanTomorrowReminderToConsultantNotification( type: type, - planName: json['plan_name'], - customerName: json['client_name'], + planName: json['plan_name'] ?? '', + customerName: json['client_name'] ?? '', + chatId: json['chat_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -276,14 +402,22 @@ class PlanSubscriptionPaidToAuthorNotification implements BaseNotificationModel @override final bool isRead; final String planName; + final int planId; final String customerName; + final int userId; + final String userPhotoPath; + final String chatUrl; @override final DateTime createdAt; PlanSubscriptionPaidToAuthorNotification({ required this.type, required this.isRead, required this.planName, + required this.planId, required this.customerName, + required this.userId, + required this.userPhotoPath, + required this.chatUrl, required this.createdAt, }); @@ -291,8 +425,12 @@ class PlanSubscriptionPaidToAuthorNotification implements BaseNotificationModel return PlanSubscriptionPaidToAuthorNotification( type: type, isRead: isRead ?? false, - planName: json['plan_name'], - customerName: json['user_name'], + planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + customerName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } @@ -306,6 +444,9 @@ class ChosenPlanFinishedToConsultantNotification implements BaseNotificationMode final String planName; final String customerName; final int planId; + final int chosenPlanId; + final String clientPhotoPath; + final int clientId; @override final DateTime createdAt; ChosenPlanFinishedToConsultantNotification({ @@ -313,6 +454,9 @@ class ChosenPlanFinishedToConsultantNotification implements BaseNotificationMode required this.planName, required this.customerName, required this.planId, + required this.chosenPlanId, + required this.clientPhotoPath, + required this.clientId, required this.isRead, required this.createdAt, }); @@ -320,9 +464,12 @@ class ChosenPlanFinishedToConsultantNotification implements BaseNotificationMode factory ChosenPlanFinishedToConsultantNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanFinishedToConsultantNotification( type: type, - planName: json['plan_name'], - customerName: json['client_name'], - planId: json['chosen_plan_id'], + planName: json['plan_name'] ?? '', + customerName: json['client_name'] ?? '', + planId: json['plan_id'] ?? 0, + chosenPlanId: json['chosen_plan_id'] ?? 0, + clientPhotoPath: json['client_photo_path'] ?? '', + clientId: json['client_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -334,15 +481,31 @@ class ChosenPlanLastDayFinishedForUser implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + final String planName; + final int chosenPlanId; + final String complaintUrl; + final String chatUrl; @override final DateTime createdAt; - ChosenPlanLastDayFinishedForUser({required this.type, required this.isRead, required this.createdAt}); + ChosenPlanLastDayFinishedForUser({ + required this.type, + required this.isRead, + required this.planName, + required this.chosenPlanId, + required this.complaintUrl, + required this.chatUrl, + required this.createdAt, + }); factory ChosenPlanLastDayFinishedForUser.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanLastDayFinishedForUser( type: type, isRead: isRead ?? false, + planName: json['plan_name'] ?? '', + chosenPlanId: json['chosen_plan_id'] ?? 0, + complaintUrl: json['complaintUrl'] ?? json['complaint_url'] ?? '', + chatUrl: json['chatUrl'] ?? json['chat_url'] ?? '', createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } @@ -373,10 +536,10 @@ class ChosenPlanConsultantLastDayStarted implements BaseNotificationModel { factory ChosenPlanConsultantLastDayStarted.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanConsultantLastDayStarted( type: type, - planName: json['plan_name'], - planId: json['plan_id'], - chatId: json['chat_id'], - chatUrl: json['chat_url'], + planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + chatId: json['chat_id']?.toString() ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -391,6 +554,7 @@ class ComplaintToModerationForUser implements BaseNotificationModel { final String message; final int entityId; final String entityType; + final String entityName; @override final DateTime createdAt; @@ -399,6 +563,7 @@ class ComplaintToModerationForUser implements BaseNotificationModel { required this.message, required this.entityId, required this.entityType, + required this.entityName, required this.isRead, required this.createdAt, }); @@ -406,9 +571,10 @@ class ComplaintToModerationForUser implements BaseNotificationModel { factory ComplaintToModerationForUser.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ComplaintToModerationForUser( type: type, - message: json['message'], - entityId: json['entity_id'], - entityType: json['entity_type'], + message: json['message'] ?? '', + entityId: json['entity_id'] ?? 0, + entityType: json['entity_type'] ?? '', + entityName: json['entity_name'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -860,15 +1026,17 @@ class DietCancelledNotification implements BaseNotificationModel { @override final bool isRead; final String dietName; + final int dietId; @override final DateTime createdAt; - DietCancelledNotification({required this.type, required this.dietName, required this.isRead, required this.createdAt}); + DietCancelledNotification({required this.type, required this.dietName, required this.dietId, required this.isRead, required this.createdAt}); factory DietCancelledNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return DietCancelledNotification( type: type, dietName: json['diet_name'] ?? '', + dietId: json['diet_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -881,15 +1049,17 @@ class DietStartedNotification implements BaseNotificationModel { @override final bool isRead; final String dietName; + final int dietId; @override final DateTime createdAt; - DietStartedNotification({required this.type, required this.dietName, required this.isRead, required this.createdAt}); + DietStartedNotification({required this.type, required this.dietName, required this.dietId, required this.isRead, required this.createdAt}); factory DietStartedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return DietStartedNotification( type: type, dietName: json['diet_name'] ?? '', + dietId: json['diet_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -902,15 +1072,17 @@ class DietExpiredNotification implements BaseNotificationModel { @override final bool isRead; final String dietName; + final int dietId; @override final DateTime createdAt; - DietExpiredNotification({required this.type, required this.dietName, required this.isRead, required this.createdAt}); + DietExpiredNotification({required this.type, required this.dietName, required this.dietId, required this.isRead, required this.createdAt}); factory DietExpiredNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return DietExpiredNotification( type: type, dietName: json['diet_name'] ?? '', + dietId: json['diet_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -923,16 +1095,25 @@ class DietWillStartedInNotification implements BaseNotificationModel { @override final bool isRead; final String dietName; + final int dietId; final String startDate; @override final DateTime createdAt; - DietWillStartedInNotification({required this.type, required this.dietName, required this.startDate, required this.isRead, required this.createdAt}); + DietWillStartedInNotification({ + required this.type, + required this.dietName, + required this.dietId, + required this.startDate, + required this.isRead, + required this.createdAt, + }); factory DietWillStartedInNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return DietWillStartedInNotification( type: type, dietName: json['diet_name'] ?? '', + dietId: json['diet_id'] ?? 0, startDate: json['start_date'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), @@ -946,16 +1127,25 @@ class DietCanNotBeStartedNotification implements BaseNotificationModel { @override final bool isRead; final String dietName; + final int dietId; final String reason; @override final DateTime createdAt; - DietCanNotBeStartedNotification({required this.type, required this.dietName, required this.reason, required this.isRead, required this.createdAt}); + DietCanNotBeStartedNotification({ + required this.type, + required this.dietName, + required this.dietId, + required this.reason, + required this.isRead, + required this.createdAt, + }); factory DietCanNotBeStartedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return DietCanNotBeStartedNotification( type: type, dietName: json['diet_name'] ?? '', + dietId: json['diet_id'] ?? 0, reason: json['reason'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), @@ -968,18 +1158,27 @@ class PersonalDietCreationNeededNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; - final String planName; - final String clientName; + final String userName; + final String consultantName; + final String myDietsUrl; @override final DateTime createdAt; - PersonalDietCreationNeededNotification({required this.type, required this.planName, required this.clientName, required this.isRead, required this.createdAt}); + PersonalDietCreationNeededNotification({ + required this.type, + required this.userName, + required this.consultantName, + required this.myDietsUrl, + required this.isRead, + required this.createdAt, + }); factory PersonalDietCreationNeededNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return PersonalDietCreationNeededNotification( type: type, - planName: json['plan_name'] ?? '', - clientName: json['client_name'] ?? '', + userName: json['user_name'] ?? '', + consultantName: json['consultant_name'] ?? '', + myDietsUrl: json['myDietsUrl'] ?? json['my_diets_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -992,15 +1191,29 @@ class ChosenPlanClientLastDayFinishedNotification implements BaseNotificationMod @override final bool isRead; final String planName; + final int chosenPlanId; + final String complaintUrl; + final String chatUrl; @override final DateTime createdAt; - ChosenPlanClientLastDayFinishedNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + ChosenPlanClientLastDayFinishedNotification({ + required this.type, + required this.planName, + required this.chosenPlanId, + required this.complaintUrl, + required this.chatUrl, + required this.isRead, + required this.createdAt, + }); factory ChosenPlanClientLastDayFinishedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanClientLastDayFinishedNotification( type: type, planName: json['plan_name'] ?? '', + chosenPlanId: json['chosen_plan_id'] ?? 0, + complaintUrl: json['complaintUrl'] ?? json['complaint_url'] ?? '', + chatUrl: json['chatUrl'] ?? json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1013,15 +1226,26 @@ class ChosenPlanClientLastDayStartedNotification implements BaseNotificationMode @override final bool isRead; final String planName; + final int chosenPlanId; + final String lastDayDiaryUrl; @override final DateTime createdAt; - ChosenPlanClientLastDayStartedNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + ChosenPlanClientLastDayStartedNotification({ + required this.type, + required this.planName, + required this.chosenPlanId, + required this.lastDayDiaryUrl, + required this.isRead, + required this.createdAt, + }); factory ChosenPlanClientLastDayStartedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenPlanClientLastDayStartedNotification( type: type, planName: json['plan_name'] ?? '', + chosenPlanId: json['chosen_plan_id'] ?? 0, + lastDayDiaryUrl: json['last_day_diary_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1034,18 +1258,18 @@ class StayOnTrackToAuthorNotification implements BaseNotificationModel { @override final bool isRead; final String planName; - final String clientName; - final int planId; - final int clientId; + final String userName; + final int dayNum; + final int diaryDayId; @override final DateTime createdAt; StayOnTrackToAuthorNotification({ required this.type, required this.planName, - required this.clientName, - required this.planId, - required this.clientId, + required this.userName, + required this.dayNum, + required this.diaryDayId, required this.isRead, required this.createdAt, }); @@ -1054,9 +1278,9 @@ class StayOnTrackToAuthorNotification implements BaseNotificationModel { return StayOnTrackToAuthorNotification( type: type, planName: json['plan_name'] ?? '', - clientName: json['client_name'] ?? '', - planId: json['plan_id'] ?? 0, - clientId: json['client_id'] ?? 0, + userName: json['user_name'] ?? '', + dayNum: json['day_num'] ?? 0, + diaryDayId: json['diary_day_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1069,15 +1293,41 @@ class ClientStartPlanSubscriptionToSubscriberNotification implements BaseNotific @override final bool isRead; final String planName; + final int planId; + final String authorName; + final int authorId; + final String authorPhotoPath; + final String chatUrl; + final String chatId; + final String startDate; @override final DateTime createdAt; - ClientStartPlanSubscriptionToSubscriberNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + ClientStartPlanSubscriptionToSubscriberNotification({ + required this.type, + required this.planName, + required this.planId, + required this.authorName, + required this.authorId, + required this.authorPhotoPath, + required this.chatUrl, + required this.chatId, + required this.startDate, + required this.isRead, + required this.createdAt, + }); factory ClientStartPlanSubscriptionToSubscriberNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ClientStartPlanSubscriptionToSubscriberNotification( type: type, planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + authorName: json['author_name'] ?? '', + authorId: json['author_id'] ?? 0, + authorPhotoPath: json['author_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', + chatId: json['chat_id']?.toString() ?? '', + startDate: json['start_date']?.toString() ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1090,14 +1340,22 @@ class ConsultantConfirmPlanSubscriptionToSubscriberNotification implements BaseN @override final bool isRead; final String planName; + final int planId; final String consultantName; + final int authorId; + final String authorPhotoPath; + final String chatUrl; @override final DateTime createdAt; ConsultantConfirmPlanSubscriptionToSubscriberNotification({ required this.type, required this.planName, + required this.planId, required this.consultantName, + required this.authorId, + required this.authorPhotoPath, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -1111,7 +1369,11 @@ class ConsultantConfirmPlanSubscriptionToSubscriberNotification implements BaseN return ConsultantConfirmPlanSubscriptionToSubscriberNotification( type: type, planName: json['plan_name'] ?? '', - consultantName: json['consultant_name'] ?? '', + planId: json['plan_id'] ?? 0, + consultantName: json['author_name'] ?? json['consultant_name'] ?? '', + authorId: json['author_id'] ?? 0, + authorPhotoPath: json['author_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1124,16 +1386,22 @@ class ClientPaidPlanSubscriptionToAuthorNotification implements BaseNotification @override final bool isRead; final String planName; + final int planId; final String clientName; - final double amount; + final int userId; + final String userPhotoPath; + final String chatUrl; @override final DateTime createdAt; ClientPaidPlanSubscriptionToAuthorNotification({ required this.type, required this.planName, + required this.planId, required this.clientName, - required this.amount, + required this.userId, + required this.userPhotoPath, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -1142,8 +1410,11 @@ class ClientPaidPlanSubscriptionToAuthorNotification implements BaseNotification return ClientPaidPlanSubscriptionToAuthorNotification( type: type, planName: json['plan_name'] ?? '', - clientName: json['client_name'] ?? '', - amount: (json['amount'] as num?)?.toDouble() ?? 0.0, + planId: json['plan_id'] ?? 0, + clientName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1155,7 +1426,10 @@ class ConsultantLeftDiaryCommentNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + final int chosenPlanId; + final int diaryId; final String planName; + final String userName; final String consultantName; final DateTime diaryDate; @override @@ -1163,7 +1437,10 @@ class ConsultantLeftDiaryCommentNotification implements BaseNotificationModel { ConsultantLeftDiaryCommentNotification({ required this.type, + required this.chosenPlanId, + required this.diaryId, required this.planName, + required this.userName, required this.consultantName, required this.diaryDate, required this.isRead, @@ -1173,7 +1450,10 @@ class ConsultantLeftDiaryCommentNotification implements BaseNotificationModel { factory ConsultantLeftDiaryCommentNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ConsultantLeftDiaryCommentNotification( type: type, + chosenPlanId: json['chosen_plan_id'] ?? 0, + diaryId: json['diary_id'] ?? 0, planName: json['plan_name'] ?? '', + userName: json['user_name'] ?? '', consultantName: json['consultant_name'] ?? '', diaryDate: parseDate(json['diary_date'] ?? DateTime.now().toIso8601String()), isRead: isRead ?? false, @@ -1188,14 +1468,22 @@ class ConsultantConfirmPlanSubscriptionToAuthorNotification implements BaseNotif @override final bool isRead; final String planName; - final String consultantName; + final int planId; + final String clientName; + final int userId; + final String userPhotoPath; + final String chatUrl; @override final DateTime createdAt; ConsultantConfirmPlanSubscriptionToAuthorNotification({ required this.type, required this.planName, - required this.consultantName, + required this.planId, + required this.clientName, + required this.userId, + required this.userPhotoPath, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -1204,7 +1492,11 @@ class ConsultantConfirmPlanSubscriptionToAuthorNotification implements BaseNotif return ConsultantConfirmPlanSubscriptionToAuthorNotification( type: type, planName: json['plan_name'] ?? '', - consultantName: json['consultant_name'] ?? '', + planId: json['plan_id'] ?? 0, + clientName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1217,14 +1509,22 @@ class ClientPaidPlanSubscriptionToSubscriberNotification implements BaseNotifica @override final bool isRead; final String planName; - final double amount; + final int planId; + final String authorName; + final int authorId; + final String authorPhotoPath; + final String chatUrl; @override final DateTime createdAt; ClientPaidPlanSubscriptionToSubscriberNotification({ required this.type, required this.planName, - required this.amount, + required this.planId, + required this.authorName, + required this.authorId, + required this.authorPhotoPath, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -1233,7 +1533,11 @@ class ClientPaidPlanSubscriptionToSubscriberNotification implements BaseNotifica return ClientPaidPlanSubscriptionToSubscriberNotification( type: type, planName: json['plan_name'] ?? '', - amount: (json['amount'] as num?)?.toDouble() ?? 0.0, + planId: json['plan_id'] ?? 0, + authorName: json['author_name'] ?? '', + authorId: json['author_id'] ?? 0, + authorPhotoPath: json['author_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1246,14 +1550,24 @@ class ClientStartPlanSubscriptionToAuthorNotification implements BaseNotificatio @override final bool isRead; final String planName; + final int planId; final String clientName; + final int userId; + final String userPhotoPath; + final String chatId; + final String chatUrl; @override final DateTime createdAt; ClientStartPlanSubscriptionToAuthorNotification({ required this.type, required this.planName, + required this.planId, required this.clientName, + required this.userId, + required this.userPhotoPath, + required this.chatId, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -1262,7 +1576,12 @@ class ClientStartPlanSubscriptionToAuthorNotification implements BaseNotificatio return ClientStartPlanSubscriptionToAuthorNotification( type: type, planName: json['plan_name'] ?? '', - clientName: json['client_name'] ?? '', + planId: json['plan_id'] ?? 0, + clientName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatId: json['chat_id']?.toString() ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1275,14 +1594,22 @@ class ClientCancelPlanSubscriptionToAuthorNotification implements BaseNotificati @override final bool isRead; final String planName; + final int planId; final String clientName; + final int userId; + final String userPhotoPath; + final String chatUrl; @override final DateTime createdAt; ClientCancelPlanSubscriptionToAuthorNotification({ required this.type, required this.planName, + required this.planId, required this.clientName, + required this.userId, + required this.userPhotoPath, + required this.chatUrl, required this.isRead, required this.createdAt, }); @@ -1291,7 +1618,11 @@ class ClientCancelPlanSubscriptionToAuthorNotification implements BaseNotificati return ClientCancelPlanSubscriptionToAuthorNotification( type: type, planName: json['plan_name'] ?? '', - clientName: json['client_name'] ?? '', + planId: json['plan_id'] ?? 0, + clientName: json['user_name'] ?? '', + userId: json['user_id'] ?? 0, + userPhotoPath: json['user_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1304,15 +1635,35 @@ class ClientCancelPlanSubscriptionToSubscriberNotification implements BaseNotifi @override final bool isRead; final String planName; + final int planId; + final String authorName; + final int authorId; + final String authorPhotoPath; + final String chatUrl; @override final DateTime createdAt; - ClientCancelPlanSubscriptionToSubscriberNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); + ClientCancelPlanSubscriptionToSubscriberNotification({ + required this.type, + required this.planName, + required this.planId, + required this.authorName, + required this.authorId, + required this.authorPhotoPath, + required this.chatUrl, + required this.isRead, + required this.createdAt, + }); factory ClientCancelPlanSubscriptionToSubscriberNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ClientCancelPlanSubscriptionToSubscriberNotification( type: type, planName: json['plan_name'] ?? '', + planId: json['plan_id'] ?? 0, + authorName: json['author_name'] ?? '', + authorId: json['author_id'] ?? 0, + authorPhotoPath: json['author_photo_path'] ?? '', + chatUrl: json['chat_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1324,17 +1675,21 @@ class UserDailyReportCompletedNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + final int chosenPlanId; + final int diaryId; final String planName; final String clientName; - final DateTime reportDate; + final String consultantName; @override final DateTime createdAt; UserDailyReportCompletedNotification({ required this.type, + required this.chosenPlanId, + required this.diaryId, required this.planName, required this.clientName, - required this.reportDate, + required this.consultantName, required this.isRead, required this.createdAt, }); @@ -1342,9 +1697,11 @@ class UserDailyReportCompletedNotification implements BaseNotificationModel { factory UserDailyReportCompletedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return UserDailyReportCompletedNotification( type: type, + chosenPlanId: json['chosen_plan_id'] ?? 0, + diaryId: json['diary_id'] ?? 0, planName: json['plan_name'] ?? '', - clientName: json['client_name'] ?? '', - reportDate: parseDate(json['report_date'] ?? DateTime.now().toIso8601String()), + clientName: json['user_name'] ?? '', + consultantName: json['consultant_name'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1357,18 +1714,16 @@ class StayOnTrackToCustomerNotification implements BaseNotificationModel { @override final bool isRead; final String planName; - final String consultantName; - final int planId; - final int consultantId; + final int dayNum; + final int diaryDayId; @override final DateTime createdAt; StayOnTrackToCustomerNotification({ required this.type, required this.planName, - required this.consultantName, - required this.planId, - required this.consultantId, + required this.dayNum, + required this.diaryDayId, required this.isRead, required this.createdAt, }); @@ -1377,9 +1732,8 @@ class StayOnTrackToCustomerNotification implements BaseNotificationModel { return StayOnTrackToCustomerNotification( type: type, planName: json['plan_name'] ?? '', - consultantName: json['consultant_name'] ?? '', - planId: json['plan_id'] ?? 0, - consultantId: json['consultant_id'] ?? 0, + dayNum: json['day_num'] ?? 0, + diaryDayId: json['diary_day_id'] ?? 0, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1414,18 +1768,27 @@ class SubscriptionNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; - final String subscriptionType; - final String planName; + final String userName; + final String userPhotoPath; + final String userProfileUrl; @override final DateTime createdAt; - SubscriptionNotification({required this.type, required this.subscriptionType, required this.planName, required this.isRead, required this.createdAt}); + SubscriptionNotification({ + required this.type, + required this.userName, + required this.userPhotoPath, + required this.userProfileUrl, + required this.isRead, + required this.createdAt, + }); factory SubscriptionNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return SubscriptionNotification( type: type, - subscriptionType: json['subscription_type'] ?? '', - planName: json['plan_name'] ?? '', + userName: json['user_name'] ?? '', + userPhotoPath: json['user_photo_path'] ?? '', + userProfileUrl: json['user_profile_url'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1437,18 +1800,27 @@ class BadgeAwardedNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; - final String badgeName; - final String badgeDescription; + final String badgeTitle; + final String badgeType; + final String badgeImage; @override final DateTime createdAt; - BadgeAwardedNotification({required this.type, required this.badgeName, required this.badgeDescription, required this.isRead, required this.createdAt}); + BadgeAwardedNotification({ + required this.type, + required this.badgeTitle, + required this.badgeType, + required this.badgeImage, + required this.isRead, + required this.createdAt, + }); factory BadgeAwardedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return BadgeAwardedNotification( type: type, - badgeName: json['badge_name'] ?? '', - badgeDescription: json['badge_description'] ?? '', + badgeTitle: json['badge_title'] ?? '', + badgeType: json['badge_type'] ?? '', + badgeImage: json['badge_image'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1460,18 +1832,27 @@ class BadgeRevokedNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; - final String badgeName; - final String reason; + final String badgeTitle; + final String badgeType; + final String badgeImage; @override final DateTime createdAt; - BadgeRevokedNotification({required this.type, required this.badgeName, required this.reason, required this.isRead, required this.createdAt}); + BadgeRevokedNotification({ + required this.type, + required this.badgeTitle, + required this.badgeType, + required this.badgeImage, + required this.isRead, + required this.createdAt, + }); factory BadgeRevokedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return BadgeRevokedNotification( type: type, - badgeName: json['badge_name'] ?? '', - reason: json['reason'] ?? '', + badgeTitle: json['badge_title'] ?? '', + badgeType: json['badge_type'] ?? '', + badgeImage: json['badge_image'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1504,15 +1885,13 @@ class ClientCloseComplaintSuccessNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; - final String complaintType; - final String entityName; + final String planName; @override final DateTime createdAt; ClientCloseComplaintSuccessNotification({ required this.type, - required this.complaintType, - required this.entityName, + required this.planName, required this.isRead, required this.createdAt, }); @@ -1520,8 +1899,7 @@ class ClientCloseComplaintSuccessNotification implements BaseNotificationModel { factory ClientCloseComplaintSuccessNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ClientCloseComplaintSuccessNotification( type: type, - complaintType: json['complaint_type'] ?? '', - entityName: json['entity_name'] ?? '', + planName: json['plan_name'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1598,17 +1976,15 @@ class ClientRejectSuccessNotification implements BaseNotificationModel { @override final bool isRead; final String planName; - final String reason; @override final DateTime createdAt; - ClientRejectSuccessNotification({required this.type, required this.planName, required this.reason, required this.isRead, required this.createdAt}); + ClientRejectSuccessNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); factory ClientRejectSuccessNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ClientRejectSuccessNotification( type: type, planName: json['plan_name'] ?? '', - reason: json['reason'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1622,7 +1998,6 @@ class ConsultantRejectSuccessNotification implements BaseNotificationModel { final bool isRead; final String planName; final String clientName; - final String reason; @override final DateTime createdAt; @@ -1630,7 +2005,6 @@ class ConsultantRejectSuccessNotification implements BaseNotificationModel { required this.type, required this.planName, required this.clientName, - required this.reason, required this.isRead, required this.createdAt, }); @@ -1639,8 +2013,7 @@ class ConsultantRejectSuccessNotification implements BaseNotificationModel { return ConsultantRejectSuccessNotification( type: type, planName: json['plan_name'] ?? '', - clientName: json['client_name'] ?? '', - reason: json['reason'] ?? '', + clientName: json['user_name'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1694,18 +2067,24 @@ class ProductStatusUpdatedForAuthorNotification implements BaseNotificationModel @override final bool isRead; final String productName; - final String oldStatus; - final String newStatus; - final String status; // Added to capture single status if old/new aren't provided + final int productId; + final String author; + final int authorId; + final String status; + final String subject; + final String messageLine; @override final DateTime createdAt; ProductStatusUpdatedForAuthorNotification({ required this.type, required this.productName, - this.oldStatus = '', - this.newStatus = '', - this.status = '', + required this.productId, + required this.author, + required this.authorId, + required this.status, + required this.subject, + required this.messageLine, required this.isRead, required this.createdAt, }); @@ -1714,9 +2093,12 @@ class ProductStatusUpdatedForAuthorNotification implements BaseNotificationModel return ProductStatusUpdatedForAuthorNotification( type: type, productName: json['product_name'] ?? '', - oldStatus: json['old_status'] ?? '', - newStatus: json['new_status'] ?? '', + productId: json['product_id'] ?? 0, + author: json['author'] ?? '', + authorId: json['author_id'] ?? 0, status: json['status'] ?? '', + subject: json['subject'] ?? '', + messageLine: json['messageLine'] ?? json['message_line'] ?? '', isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); @@ -1728,15 +2110,26 @@ class DiaryReminderForUsersNotification implements BaseNotificationModel { final NotificationType type; @override final bool isRead; + final int? diaryId; + final String? message; @override final DateTime createdAt; - DiaryReminderForUsersNotification({required this.type, required this.isRead, required this.createdAt}); + DiaryReminderForUsersNotification({ + required this.type, + required this.isRead, + this.diaryId, + this.message, + required this.createdAt, + }); factory DiaryReminderForUsersNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { + final data = json['data'] is Map ? json['data'] as Map : json; return DiaryReminderForUsersNotification( type: type, isRead: isRead ?? false, + diaryId: data['diary_id'], + message: data['message'], createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), ); } @@ -1749,15 +2142,29 @@ class PersonalDietIsDefinedToCustomerNotification implements BaseNotificationMod final bool isRead; @override final DateTime createdAt; - final String dietName; + final int chosenPlanId; final int dietId; + final String planName; + final String userName; + final String consultantName; + final String dietName; + final String role; + final String peerSlug; + final String peerImagePath; PersonalDietIsDefinedToCustomerNotification({ required this.type, required this.isRead, required this.createdAt, - required this.dietName, + required this.chosenPlanId, required this.dietId, + required this.planName, + required this.userName, + required this.consultantName, + required this.dietName, + required this.role, + required this.peerSlug, + required this.peerImagePath, }); factory PersonalDietIsDefinedToCustomerNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { @@ -1765,8 +2172,15 @@ class PersonalDietIsDefinedToCustomerNotification implements BaseNotificationMod type: type, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), + chosenPlanId: json['chosen_plan_id'] ?? 0, + dietId: json['diet_id'] ?? 0, + planName: json['plan_name'] ?? '', + userName: json['user_name'] ?? '', + consultantName: json['consultant_name'] ?? '', dietName: json['diet_name'] ?? '', - dietId: json['diet_id'], + role: json['role']?.toString() ?? '', + peerSlug: json['peer_slug'] ?? '', + peerImagePath: json['peer_image_path'] ?? '', ); } } @@ -1806,14 +2220,18 @@ class ComplaintRegisteredNotification implements BaseNotificationModel { final bool isRead; @override final DateTime createdAt; - final String complaintType; + final String message; + final int entityId; + final String entityType; final String entityName; ComplaintRegisteredNotification({ required this.type, required this.isRead, required this.createdAt, - required this.complaintType, + required this.message, + required this.entityId, + required this.entityType, required this.entityName, }); @@ -1822,7 +2240,9 @@ class ComplaintRegisteredNotification implements BaseNotificationModel { type: type, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), - complaintType: json['complaint_type'] ?? '', + message: json['message'] ?? '', + entityId: json['entity_id'] ?? 0, + entityType: json['entity_type'] ?? '', entityName: json['entity_name'] ?? '', ); } @@ -1946,12 +2366,14 @@ class ChosenDietExpiredNotification implements BaseNotificationModel { @override final DateTime createdAt; final String dietName; + final int dietId; ChosenDietExpiredNotification({ required this.type, required this.isRead, required this.createdAt, required this.dietName, + required this.dietId, }); factory ChosenDietExpiredNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { @@ -1960,6 +2382,7 @@ class ChosenDietExpiredNotification implements BaseNotificationModel { isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), dietName: json['diet_name'] ?? '', + dietId: json['diet_id'] ?? 0, ); } } @@ -2052,15 +2475,17 @@ class UserSubscribedNotification implements BaseNotificationModel { final bool isRead; @override final DateTime createdAt; - final String subscriberName; - final int subscriberId; + final String userName; + final String userPhotoPath; + final String userProfileUrl; UserSubscribedNotification({ required this.type, required this.isRead, required this.createdAt, - required this.subscriberName, - required this.subscriberId, + required this.userName, + required this.userPhotoPath, + required this.userProfileUrl, }); factory UserSubscribedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { @@ -2068,8 +2493,9 @@ class UserSubscribedNotification implements BaseNotificationModel { type: type, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), - subscriberName: json['subscriber_name'] ?? '', - subscriberId: json['subscriber_id'] ?? 0, + userName: json['user_name'] ?? '', + userPhotoPath: json['user_photo_path'] ?? '', + userProfileUrl: json['user_profile_url'] ?? '', ); } } diff --git a/lib/features/notifications/presentation/widgets/notification_widget.dart b/lib/features/notifications/presentation/widgets/notification_widget.dart index 1c2d1c58..f33b9cb1 100644 --- a/lib/features/notifications/presentation/widgets/notification_widget.dart +++ b/lib/features/notifications/presentation/widgets/notification_widget.dart @@ -1,202 +1,21 @@ import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; +import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart'; import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart'; import 'package:eatvio_app/features/notifications/models/notification_model.dart'; +import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - class NotificationWidget extends StatelessWidget { const NotificationWidget({super.key, required this.notification}); final BaseNotificationModel notification; - void _onTap(BuildContext context) { - // Mark as read first - context.read().markNotificationsAsRead(); - - switch (notification) { - case CommentReplied n: - _navigateToEntity(context, n.commentedEntityType, n.commentedEntityId, n.commentId); - case EntityCommented n: - _navigateToEntity(context, n.entityType, n.entityId, n.commentId); - case PersonalDietIsDefinedToCustomerNotification n: - context.push(RouteConstants.aboutDietScreen, extra: {'id': n.dietId}); - case PlanSubscriptionStartedToAuthorNotification n: - if (n.chatId.isNotEmpty && n.chatId != '0') { - context.push(RouteConstants.chatScreen, extra: int.tryParse(n.chatId) ?? 0); - } else { - // Fallback if no chat ID - context.push(RouteConstants.profileScreen); - } - case ConsultantAssignedNotification n: - context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); - case StayOnTrackToAuthorNotification n: - context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); - case StayOnTrackToCustomerNotification n: - context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); - case ChosenPlanStartedToClientNotification n: - context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); - case ChosenPlanStartedToConsultantNotification n: - context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); - case ProductStatusUpdatedForAuthorNotification(): - context.push(RouteConstants.profileScreen); - // New Cases with Safety Checks - case NewChatMessageNotification n: - if (n.chatId != 0) context.push(RouteConstants.chatScreen, extra: n.chatId); - case FinanceQuestionCreatedNotification(): - context.push(RouteConstants.userFinanceScreen); - case ChosenDietExpiredNotification(): - context.push(RouteConstants.myDietsScreen); - case ContentUnpublishedNotification(): - context.push(RouteConstants.profileScreen); - case CompleteRegistrationNotification(): - context.push(RouteConstants.profileScreen); // Or edit profile - case ConsultantEducationUpdatedNotification(): - context.push(RouteConstants.profileScreen); - case ComplaintRegisteredNotification(): - context.push(RouteConstants.profileScreen); - case ConsultantRequestStartedNotification(): - // Navigate to where requests are shown - context.push(RouteConstants.profileScreen); - case ClientCloseComplaintSuccessNotification(): - context.push(RouteConstants.profileScreen); - case ClientRefundSuccessNotification(): - context.push(RouteConstants.userFinanceScreen); - case ConsultantRefundSuccessNotification(): - context.push(RouteConstants.userFinanceScreen); - case ClientRejectSuccessNotification(): - context.push(RouteConstants.aboutPlanScreen); // Best guess - case ConsultantRejectSuccessNotification(): - context.push(RouteConstants.aboutPlanScreen); - case ScoreSubstractedNotification(): - context.push(RouteConstants.profileScreen); - case ScoreAdded(): - context.push(RouteConstants.profileScreen); - case DiaryReminderForUsersNotification(): - context.push(RouteConstants.myDietsScreen); // Likely where diary is - case MotivateUserNotification(): - context.push(RouteConstants.myDietsScreen); - case CommentComplaintNotification(): - context.push(RouteConstants.profileScreen); - case CommentDeletedNotification(): - context.push(RouteConstants.profileScreen); - case UserSubscribedNotification(): - context.push(RouteConstants.profileScreen); - case BadgeAwardedNotification(): - context.push(RouteConstants.profileScreen); - case BadgeRevokedNotification(): - context.push(RouteConstants.profileScreen); - case SubscriptionNotification(): - context.push(RouteConstants.userFinanceScreen); - case PhoneChangedNotification(): - context.push(RouteConstants.profileScreen); - case UserDailyReportCompletedNotification(): - // Attempt to go to plan if we had ID, but we don't in this model. Go to plans list. - context.push(RouteConstants.myPlansScreen); - case ClientCancelPlanSubscriptionToSubscriberNotification(): - context.push(RouteConstants.myPlansScreen); - case ClientCancelPlanSubscriptionToAuthorNotification(): - context.push(RouteConstants.myPlansScreen); - case ClientStartPlanSubscriptionToAuthorNotification(): - context.push(RouteConstants.myPlansScreen); - case ClientPaidPlanSubscriptionToSubscriberNotification(): - context.push(RouteConstants.userFinanceScreen); - case ConsultantConfirmPlanSubscriptionToAuthorNotification(): - context.push(RouteConstants.myPlansScreen); - case ConsultantLeftDiaryCommentNotification(): - // Needs planID to go to specific plan, model has planName. - // Optimally we'd go to the diary. For now plans screen. - context.push(RouteConstants.myPlansScreen); - case ClientPaidPlanSubscriptionToAuthorNotification(): - context.push(RouteConstants.userFinanceScreen); - case ConsultantConfirmPlanSubscriptionToSubscriberNotification(): - context.push(RouteConstants.myPlansScreen); - case ClientStartPlanSubscriptionToSubscriberNotification(): - context.push(RouteConstants.myPlansScreen); - case ChosenPlanClientLastDayStartedNotification(): - context.push(RouteConstants.myPlansScreen); - case ChosenPlanClientLastDayFinishedNotification(): - context.push(RouteConstants.myPlansScreen); - case PersonalDietCreationNeededNotification(): - context.push(RouteConstants.myPlansScreen); - case DietCanNotBeStartedNotification(): - context.push(RouteConstants.myDietsScreen); - case DietWillStartedInNotification(): - context.push(RouteConstants.myDietsScreen); - case DietExpiredNotification(): - context.push(RouteConstants.myDietsScreen); - case DietStartedNotification(): - context.push(RouteConstants.myDietsScreen); - case DietCancelledNotification(): - context.push(RouteConstants.myDietsScreen); - case MealReminderNotification(): - context.push(RouteConstants.myDietsScreen); - case WorkoutReminderNotification(): - context.push(RouteConstants.myDietsScreen); - case GoalAchievedNotification(): - context.push(RouteConstants.profileScreen); - case PaymentSuccessfulNotification(): - context.push(RouteConstants.userFinanceScreen); - case PaymentFailedNotification(): - context.push(RouteConstants.userFinanceScreen); - case ConsultantUnassignedNotification n: - context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); - case PlanSubscriptionRenewedNotification(): - context.push(RouteConstants.userFinanceScreen); - case PlanSubscriptionExpiredNotification(): - context.push(RouteConstants.userFinanceScreen); - case ComplaintToModerationForUser(): - context.push(RouteConstants.profileScreen); - case ChosenPlanConsultantLastDayStarted n: - context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); - case ChosenPlanLastDayFinishedForUser(): - context.push(RouteConstants.myPlansScreen); - case ChosenPlanFinishedToConsultantNotification n: - context.push(RouteConstants.aboutPlanScreen, extra: {'id': n.planId}); - case PlanSubscriptionPaidToAuthorNotification(): - context.push(RouteConstants.userFinanceScreen); - case ChosenPlanTomorrowReminderToConsultantNotification(): - context.push(RouteConstants.myPlansScreen); - case ChosenPlanTomorrowReminderToClientNotification(): - context.push(RouteConstants.myPlansScreen); - case PlanSubscriptionCanceledToCustomerNotification(): - context.push(RouteConstants.myPlansScreen); - case ChosenPlanFinishedToClientNotification(): - context.push(RouteConstants.myPlansScreen); - case PersonalizedDietIsDefinedNotification(): - context.push(RouteConstants.myDietsScreen); - case ConsultantLeftCommentNotification(): - context.push(RouteConstants.myPlansScreen); - case DiaryReminderNotification(): - context.push(RouteConstants.myDietsScreen); - case PlanSubscriptionCanceledToAuthorNotification(): - context.push(RouteConstants.myPlansScreen); - default: - // Do nothing if no logical destination - break; - } - } - - 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')) { - context.push(RouteConstants.aboutRecipesScreen, extra: {'id': id, 'highlightedCommentId': commentId}); - } else if (type.contains('Plan')) { - context.push(RouteConstants.aboutPlanScreen, extra: {'id': id, 'highlightedCommentId': commentId}); - } else if (type.contains('Diet')) { - context.push(RouteConstants.aboutDietScreen, extra: {'id': id, 'highlightedCommentId': commentId}); - } - } - -// }; -// } @override Widget build(BuildContext context) { return switch (notification) { @@ -303,9 +122,7 @@ class NotificationWidget extends StatelessWidget { notification: notification as PersonalDietIsDefinedToCustomerNotification, ), // New Widgets - ConsultantEducationUpdatedNotification() => _ConsultantEducationUpdatedWidget( - notification: notification as ConsultantEducationUpdatedNotification, - ), + ConsultantEducationUpdatedNotification() => _ConsultantEducationUpdatedWidget(notification: notification as ConsultantEducationUpdatedNotification), ComplaintRegisteredNotification() => _ComplaintRegisteredWidget(notification: notification as ComplaintRegisteredNotification), ContentUnpublishedNotification() => _ContentUnpublishedWidget(notification: notification as ContentUnpublishedNotification), FinanceQuestionCreatedNotification() => _FinanceQuestionCreatedWidget(notification: notification as FinanceQuestionCreatedNotification), @@ -321,6 +138,29 @@ class NotificationWidget extends StatelessWidget { } } +void _navigateToBottomNavRoute(BuildContext context, String path, [Object? extra]) { + final controller = BottomNavScope.maybeOf(context); + if (controller != null) { + controller.switchToRoute(path, extra); + } else { + context.push(path, extra: 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}); + } + } +} + class _PlanSubscriptionStartedToAuthorWidget extends StatelessWidget { const _PlanSubscriptionStartedToAuthorWidget({required this.notification}); final PlanSubscriptionStartedToAuthorNotification notification; @@ -332,7 +172,9 @@ class _PlanSubscriptionStartedToAuthorWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: '${s.userSubscribedToPlan(notification.customerName, notification.planName)}! 🎉', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.customerName.isNotEmpty ? notification.customerName : null); + }, ); } } @@ -342,13 +184,14 @@ class _PlanSubscriptionCanceledToAuthorWidget extends StatelessWidget { final PlanSubscriptionCanceledToAuthorNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Отмена подписки. Клиент ${notification.customerName} отказался от плана "${notification.planName}"', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.customerName.isNotEmpty ? notification.customerName : null); + }, ); } } @@ -358,13 +201,14 @@ class _ChosenPlanStartedToClientWidget extends StatelessWidget { final ChosenPlanStartedToClientNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Ваш план "${notification.planName}" начался! Удачи! 🚀', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen); + }, ); } } @@ -374,13 +218,11 @@ class _ChosenPlanTomorrowReminderToClientWidget extends StatelessWidget { final ChosenPlanTomorrowReminderToClientNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Напоминание: Завтра старт плана "${notification.planName}"', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -390,12 +232,15 @@ class _DiaryReminderWidget extends StatelessWidget { final DiaryReminderNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Не забудьте заполнить дневник за вчера! 📝', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + final yesterday = notification.createdAt.subtract(const Duration(days: 1)); + final dateOnly = DateTime(yesterday.year, yesterday.month, yesterday.day); + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen, dateOnly); + }, ); } } @@ -405,13 +250,14 @@ class _ConsultantLeftCommentWidget extends StatelessWidget { final ConsultantLeftCommentNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Новый комментарий от консультанта (за ${notification.date.format('dd.MM.yyyy')})', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen, notification.date); + }, ); } } @@ -425,7 +271,9 @@ class _PersonalizedDietIsDefinedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Персональная диета определена', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.consultationsScreen); + }, ); } } @@ -435,13 +283,11 @@ class _ChosenPlanFinishedToClientWidget extends StatelessWidget { final ChosenPlanFinishedToClientNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Поздравляем! План «${notification.planName}» успешно завершен! 🏆', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -451,13 +297,11 @@ class _PlanSubscriptionCanceledToCustomerWidget extends StatelessWidget { final PlanSubscriptionCanceledToCustomerNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Подписка на план "${notification.planName}" отменена.', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -467,13 +311,11 @@ class _ChosenPlanStartedToConsultantWidget extends StatelessWidget { final ChosenPlanStartedToConsultantNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Начало работы: План "${notification.planName}" с клиентом ${notification.customerName}', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -483,13 +325,11 @@ class _ChosenPlanTomorrowReminderToConsultantWidget extends StatelessWidget { final ChosenPlanTomorrowReminderToConsultantNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Напоминание: Завтра старт работы с ${notification.customerName} по плану "${notification.planName}"', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -499,13 +339,14 @@ class _PlanSubscriptionPaidToAuthorWidget extends StatelessWidget { final PlanSubscriptionPaidToAuthorNotification notification; @override Widget build(BuildContext context) { - final s = S.of(context); return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Оплата получена! Клиент ${notification.customerName} оплатил план "${notification.planName}" 💰', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + context.push(RouteConstants.consultantFinancesScreen); + }, ); } } @@ -517,46 +358,46 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget { Widget build(BuildContext context) { final s = S.of(context); return GestureDetector( - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () {}, behavior: HitTestBehavior.opaque, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('План «${notification.planName}» завершен! Поздравляем! 🎉', style: TextStyles.textMSemibold.copyWith(color: appTheme.black)), - const SizedBox(height: 12), - Text(s.userConfirmedPlanCompletionWithoutSum, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), - const SizedBox(height: 12), - CustomOutlinedButton( - text: s.collectCharge, - buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), - buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.green)), - onPressed: () { - GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.planId); - }, - ), - ], + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('План «${notification.planName}» завершен! Поздравляем! 🎉', style: TextStyles.textMSemibold.copyWith(color: appTheme.black)), + const SizedBox(height: 12), + Text(s.userConfirmedPlanCompletionWithoutSum, style: TextStyles.textSMedium.copyWith(color: appTheme.black)), + const SizedBox(height: 12), + CustomOutlinedButton( + text: s.collectCharge, + buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.green)), + onPressed: () { + GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.planId); + }, + ), + ], + ), ), - ), - if (!notification.isRead) - Container( - decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), - width: 8, - height: 8, - ), - ], - ), - const SizedBox(height: 4), - Text( - '${notification.createdAt.day} ${_getMonthName(notification.createdAt.month)} ${notification.createdAt.hour.toString().padLeft(2, '0')}:${notification.createdAt.minute.toString().padLeft(2, '0')}', - style: TextStyles.textSMedium.copyWith(color: appTheme.gray600), - ), - ], + if (!notification.isRead) + Container( + decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), + width: 8, + height: 8, + ), + ], + ), + const SizedBox(height: 4), + Text( + '${notification.createdAt.day} ${_getMonthName(notification.createdAt.month)} ${notification.createdAt.hour.toString().padLeft(2, '0')}:${notification.createdAt.minute.toString().padLeft(2, '0')}', + style: TextStyles.textSMedium.copyWith(color: appTheme.gray600), + ), + ], ), ); } @@ -674,7 +515,11 @@ class _ChosenPlanLastDayFinishedForUserWidget extends StatelessWidget { final ChosenPlanLastDayFinishedForUser notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Финишная прямая! Последний день плана завершен.', createdAt: notification.createdAt); + return _BaseNotificationWidget( + isRead: notification.isRead, + notificationsText: 'Финишная прямая! Последний день плана завершен.', + createdAt: notification.createdAt, + ); } } @@ -687,7 +532,9 @@ class _ChosenPlanConsultantLastDayStartedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Внимание: Начался последний день плана "${notification.planName}"', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.consultationsScreen); + }, ); } } @@ -701,7 +548,11 @@ class _ComplaintToModerationForUserWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Ваша жалоба принята и находится на модерации.', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: (['Plan', 'Recipe'].contains(notification.entityType) || notification.entityId <= 0 || notification.entityType.isEmpty) + ? null + : () { + _navigateToEntity(context, notification.entityType, notification.entityId, null); + }, ); } } @@ -715,7 +566,11 @@ class _CommentRepliedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Вам ответили в комментариях к: ${notification.entityName}', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: (['Plan', 'Recipe'].contains(notification.commentedEntityType) || notification.commentedEntityId <= 0 || notification.commentId == null) + ? null + : () { + _navigateToEntity(context, notification.commentedEntityType, notification.commentedEntityId, notification.commentId); + }, ); } } @@ -729,7 +584,9 @@ class _ScoreAddedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Поздравляем! Вам начислены баллы: ${notification.scoreValue} ⭐', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + context.push(RouteConstants.consultantRatingScreen); + }, ); } } @@ -743,7 +600,9 @@ class _EntityCommentedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Новый комментарий к вашей публикации "${notification.entityTranslatedType}": ${notification.entityName}', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToEntity(context, notification.entityType, notification.entityId, notification.commentId); + }, ); } } @@ -753,12 +612,7 @@ class _UnknownNotificationWidget extends StatelessWidget { final UnknownNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( - isRead: notification.isRead, - notificationsText: 'У вас новое уведомление! Пожалуйста, обновите приложение, чтобы увидеть детали.', - createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), - ); + return _BaseNotificationWidget(isRead: false, notificationsText: 'Неизвестное уведомление', createdAt: DateTime.now(), onTap: null); } } @@ -772,7 +626,6 @@ class _PlanSubscriptionExpiredWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Срок действия подписки на план "${notification.planName}" истек. Продлите, чтобы продолжить! ⏳', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -786,7 +639,6 @@ class _PlanSubscriptionRenewedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Успешное продление! Подписка на план "${notification.planName}" активна до ${notification.renewalDate} ✅', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -800,7 +652,9 @@ class _ConsultantAssignedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Вам назначен консультант ${notification.consultantName} по плану "${notification.planName}". Приветствуйте! 👋', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.consultantName.isNotEmpty ? notification.consultantName : null); + }, ); } } @@ -814,7 +668,6 @@ class _ConsultantUnassignedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Изменение в плане "${notification.planName}": консультант ${notification.consultantName} отстранен.', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -828,7 +681,9 @@ class _PaymentFailedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Не удалось провести оплату за план "${notification.planName}". Причина: ${notification.errorMessage} ❌', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + context.push(RouteConstants.userFinanceScreen); + }, ); } } @@ -842,7 +697,9 @@ class _PaymentSuccessfulWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Оплата прошла успешно! План "${notification.planName}" оплачен на сумму ${notification.amount} ${notification.currency} 💳', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + context.push(RouteConstants.userFinanceScreen); + }, ); } } @@ -856,7 +713,6 @@ class _GoalAchievedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Цель достигнута! "${notification.goalName}": ${notification.achievedValue}/${notification.targetValue} 🎯', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -870,7 +726,6 @@ class _WorkoutReminderWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Время тренировки! "${notification.workoutName}" ждет вас (${notification.duration} мин) 💪', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -884,7 +739,9 @@ class _MealReminderWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Пора подкрепиться! "${notification.mealName}" (${notification.calories} ккал) 🍎', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen); + }, ); } } @@ -899,7 +756,11 @@ class _DietCancelledWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Диета "${notification.dietName}" была отменена.', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + if (notification.dietId > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId}); + } + }, ); } } @@ -913,7 +774,11 @@ class _DietStartedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Старт диеты "${notification.dietName}"! Успехов! 🥗', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + if (notification.dietId > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId}); + } + }, ); } } @@ -927,7 +792,11 @@ class _DietExpiredWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Срок действия диеты "${notification.dietName}" подошел к концу.', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + if (notification.dietId > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId}); + } + }, ); } } @@ -941,7 +810,11 @@ class _DietWillStartedInWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Скоро старт! Диета "${notification.dietName}" начнется ${notification.startDate} ⏰', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + if (notification.dietId > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId}); + } + }, ); } } @@ -955,7 +828,11 @@ class _DietCanNotBeStartedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Внимание: Не удалось начать диету "${notification.dietName}". Причина: ${notification.reason}', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + if (notification.dietId > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId}); + } + }, ); } } @@ -967,9 +844,11 @@ class _PersonalDietCreationNeededWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Задача: Создать персональную диету для клиента ${notification.clientName} (План "${notification.planName}")', + notificationsText: 'Задача: Создать персональную диету для клиента ${notification.userName}', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + context.push(RouteConstants.myDietsScreen); + }, ); } } @@ -983,7 +862,6 @@ class _ChosenPlanClientLastDayFinishedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Поздравляем с завершением плана "${notification.planName}"! 🎉', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -997,7 +875,9 @@ class _ChosenPlanClientLastDayStartedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Сегодня последний день плана "${notification.planName}"! Финиш близко! 🏁', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen); + }, ); } } @@ -1009,9 +889,15 @@ class _StayOnTrackToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Клиент ${notification.clientName} продолжает уверенно идти по плану "${notification.planName}" 🔥', + notificationsText: 'Клиент ${notification.userName} завершил день ${notification.dayNum} по плану "${notification.planName}" 💪', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + if (notification.diaryDayId > 0) { + context.push(RouteConstants.userDiaryScreen, extra: {'diaryId': notification.diaryDayId}); + } else { + _navigateToBottomNavRoute(context, RouteConstants.consultationsScreen); + } + }, ); } } @@ -1025,7 +911,9 @@ class _ClientStartPlanSubscriptionToSubscriberWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'У вас новая подписка на план "${notification.planName}"! 🌟', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen); + }, ); } } @@ -1039,7 +927,9 @@ class _ConsultantConfirmPlanSubscriptionToSubscriberWidget extends StatelessWidg isRead: notification.isRead, notificationsText: 'Ваша подписка на план "${notification.planName}" подтверждена консультантом ${notification.consultantName} ✅', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.consultantName.isNotEmpty ? notification.consultantName : null); + }, ); } } @@ -1051,9 +941,11 @@ class _ClientPaidPlanSubscriptionToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Поступление средств: Клиент ${notification.clientName} оплатил подписку на план "${notification.planName}" (${notification.amount}) 💸', + notificationsText: 'Поступление средств: Клиент ${notification.clientName} оплатил подписку на план "${notification.planName}" 💸', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + context.push(RouteConstants.consultantFinancesScreen); + }, ); } } @@ -1067,7 +959,9 @@ class _ConsultantLeftDiaryCommentWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Ваш консультант ${notification.consultantName} прокомментировал дневник по плану "${notification.planName}" 💬', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen, notification.diaryDate); + }, ); } } @@ -1079,9 +973,11 @@ class _ConsultantConfirmPlanSubscriptionToAuthorWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Консультант ${notification.consultantName} подтвердил подписку на план "${notification.planName}" 🤝', + notificationsText: 'Клиент ${notification.clientName} подтвердил подписку на план "${notification.planName}" 🤝', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.clientName.isNotEmpty ? notification.clientName : null); + }, ); } } @@ -1093,9 +989,11 @@ class _ClientPaidPlanSubscriptionToSubscriberWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Оплата подписки на план "${notification.planName}" прошла успешно (${notification.amount}) ✅', + notificationsText: 'Оплата подписки на план "${notification.planName}" прошла успешно ✅', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + context.push(RouteConstants.consultantFinancesScreen); + }, ); } } @@ -1109,7 +1007,9 @@ class _ClientStartPlanSubscriptionToAuthorWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Новый подписчик! Клиент ${notification.clientName} начал подписку на план "${notification.planName}" 🎈', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.clientName.isNotEmpty ? notification.clientName : null); + }, ); } } @@ -1123,7 +1023,9 @@ class _ClientCancelPlanSubscriptionToAuthorWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Отмена подписки: Клиент ${notification.clientName} отказался от плана "${notification.planName}"', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.clientName.isNotEmpty ? notification.clientName : null); + }, ); } } @@ -1137,7 +1039,9 @@ class _ClientCancelPlanSubscriptionToSubscriberWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Ваша подписка на план "${notification.planName}" была отменена.', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen); + }, ); } } @@ -1151,7 +1055,9 @@ class _UserDailyReportCompletedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Отчет получен: Клиент ${notification.clientName} заполнил дневник по плану "${notification.planName}" 📝', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + context.push(UserDiaryScreen.path, extra: {'diaryId': notification.diaryId}); + }, ); } } @@ -1163,9 +1069,8 @@ class _StayOnTrackToCustomerWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Консультант ${notification.consultantName} продолжает работу с вами по плану "${notification.planName}" 💪', + notificationsText: 'Вы продолжаете идти по плану "${notification.planName}" (день ${notification.dayNum}) 💪', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1179,7 +1084,6 @@ class _PhoneChangedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Ваш номер телефона был успешно изменен на ${notification.newPhone} 📱', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1191,9 +1095,11 @@ class _SubscriptionWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Оформлена подписка ${notification.subscriptionType} на план "${notification.planName}" 🎉', + notificationsText: 'У вас новый подписчик: ${notification.userName} 🎉', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + context.push(RouteConstants.subsScreen, extra: SubType.subscribers); + }, ); } } @@ -1205,9 +1111,11 @@ class _BadgeAwardedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Новая награда! Вы получили значок "${notification.badgeName}": ${notification.badgeDescription} 🏅', + notificationsText: 'Новая награда! Вы получили значок "${notification.badgeTitle}" (${notification.badgeType}) 🏅', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.myProfileScreen); + }, ); } } @@ -1219,9 +1127,11 @@ class _BadgeRevokedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Значок "${notification.badgeName}" был отозван. Причина: ${notification.reason}', + notificationsText: 'Значок "${notification.badgeTitle}" (${notification.badgeType}) был отозван.', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.myProfileScreen); + }, ); } } @@ -1235,7 +1145,6 @@ class _ConsultantRequestStartedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Запрос отправлен консультанту ${notification.consultantName} 📩', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1247,9 +1156,8 @@ class _ClientCloseComplaintSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Ваша жалоба на ${notification.complaintType} "${notification.entityName}" была рассмотрена и закрыта.', + notificationsText: 'Ваша жалоба по плану "${notification.planName}" была рассмотрена и закрыта.', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1261,9 +1169,11 @@ class _ClientRefundSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Вам одобрен возврат средств по плану "${notification.planName}" (${notification.amount}). Причина: ${notification.reason}', + notificationsText: 'Вам одобрен возврат средств по плану "${notification.planName}" (${notification.amount}).', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + context.push(RouteConstants.userFinanceScreen); + }, ); } } @@ -1277,7 +1187,9 @@ class _ConsultantRefundSuccessWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Выполнен возврат средств клиенту ${notification.clientName} по плану "${notification.planName}" (${notification.amount})', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + context.push(RouteConstants.consultantFinancesScreen); + }, ); } } @@ -1289,9 +1201,11 @@ class _ClientRejectSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Отказ по плану "${notification.planName}" принят: ${notification.reason}', + notificationsText: 'Отказ по плану "${notification.planName}" принят.', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen); + }, ); } } @@ -1303,9 +1217,11 @@ class _ConsultantRejectSuccessWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'Клиент ${notification.clientName} отказался от плана "${notification.planName}": ${notification.reason}', + notificationsText: 'Клиент ${notification.clientName} отказался от плана "${notification.planName}".', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.clientName.isNotEmpty ? notification.clientName : null); + }, ); } } @@ -1319,7 +1235,9 @@ class _ScoreSubstractedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Списание баллов: -${notification.scoreValue} за ${notification.scorableName}', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.consultantRatingScreen); + }, ); } } @@ -1329,23 +1247,13 @@ class _ProductStatusUpdatedForAuthorWidget extends StatelessWidget { final ProductStatusUpdatedForAuthorNotification notification; @override Widget build(BuildContext context) { - String text; - if (notification.oldStatus.isNotEmpty && notification.newStatus.isNotEmpty) { - text = 'Статус продукта "${notification.productName}" изменен с ${notification.oldStatus} на ${notification.newStatus}'; - } else if (notification.newStatus.isNotEmpty) { - text = 'Статус продукта "${notification.productName}" изменен на ${notification.newStatus}'; - } else if (notification.status.isNotEmpty) { - text = 'Статус продукта "${notification.productName}": ${notification.status}'; - } else { - text = 'Статус продукта "${notification.productName}" обновлен'; - } + final text = notification.subject.isNotEmpty + ? notification.subject + : notification.status.isNotEmpty + ? 'Статус продукта "${notification.productName}": ${notification.status}' + : 'Статус продукта "${notification.productName}" обновлен'; - return _BaseNotificationWidget( - isRead: notification.isRead, - notificationsText: text, - createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), - ); + return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: text, createdAt: notification.createdAt); } } @@ -1358,7 +1266,10 @@ class _DiaryReminderForUsersWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Не забудьте заполнить дневник сегодня! 📓', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + final date = notification.createdAt; + _navigateToBottomNavRoute(context, RouteConstants.diaryScreen, date); + }, ); } } @@ -1372,7 +1283,11 @@ class _PersonalDietIsDefinedToCustomerWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Для вас составлена персональная диета "${notification.dietName}"! 🥗', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + if (notification.dietId > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId}); + } + }, ); } } @@ -1386,7 +1301,9 @@ class _ConsultantEducationUpdatedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Консультант ${notification.consultantName} обновил данные об образовании.', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.myProfileScreen); + }, ); } } @@ -1400,7 +1317,6 @@ class _ComplaintRegisteredWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Мы получили вашу жалобу на ${notification.entityName}. Спасибо за сигнал!', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1414,7 +1330,6 @@ class _ContentUnpublishedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Ваша публикация ${notification.entityName} была скрыта. Причина: ${notification.reason}', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1428,7 +1343,9 @@ class _FinanceQuestionCreatedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Служба поддержки: Новый вопрос по финансам: ${notification.questionText}', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen); + }, ); } } @@ -1442,7 +1359,9 @@ class _NewChatMessageWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Входящее сообщение от ${notification.senderName}: "${notification.messagePreview}"', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), + onTap: () { + _navigateToBottomNavRoute(context, RouteConstants.chatSelectScreen, notification.senderName.isNotEmpty ? notification.senderName : null); + }, ); } } @@ -1456,7 +1375,6 @@ class _CompleteRegistrationWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Завершите регистрацию, чтобы получить доступ ко всем функциям! 🚀', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1470,7 +1388,6 @@ class _ChosenDietExpiredWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Диета "${notification.dietName}" завершена. Время подвести итоги!', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1480,12 +1397,7 @@ class _MotivateUserWidget extends StatelessWidget { final MotivateUserNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( - isRead: notification.isRead, - notificationsText: notification.message, - createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), - ); + return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: notification.message, createdAt: notification.createdAt); } } @@ -1498,7 +1410,6 @@ class _CommentComplaintWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Поступила жалоба на ваш комментарий: "${notification.commentPreview}". Причина: ${notification.reason}', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1512,7 +1423,6 @@ class _CommentDeletedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Ваш комментарий был удален по правилам сообщества: "${notification.commentPreview}"', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } @@ -1524,9 +1434,8 @@ class _UserSubscribedWidget extends StatelessWidget { Widget build(BuildContext context) { return _BaseNotificationWidget( isRead: notification.isRead, - notificationsText: 'У вас новый подписчик: ${notification.subscriberName}! 👋', + notificationsText: 'У вас новый подписчик: ${notification.userName}! 👋', createdAt: notification.createdAt, - onTap: () => (context.findAncestorWidgetOfExactType() as NotificationWidget)._onTap(context), ); } } diff --git a/lib/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart b/lib/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart index b6d40645..693fe8d5 100644 --- a/lib/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart +++ b/lib/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart @@ -5,22 +5,30 @@ import 'package:flutter_bloc/flutter_bloc.dart'; class SubCubit extends Cubit { final ProfileRepository profileRepository; - SubCubit({ - required this.profileRepository, - }) : super(const SubState()); + SubCubit({required this.profileRepository}) : super(const SubState()); - Future getSubscriptions(String slug, [String? query]) async { + Future 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 getSubscribers(String slug, [String? query]) async { + Future 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())); diff --git a/lib/features/profile/presentation/pages/subs_screen.dart b/lib/features/profile/presentation/pages/subs_screen.dart index d5d782f2..6de5194e 100644 --- a/lib/features/profile/presentation/pages/subs_screen.dart +++ b/lib/features/profile/presentation/pages/subs_screen.dart @@ -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, From 6f666a8859010239c1c732a7917c138b2ba5238d Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 18 Feb 2026 20:19:23 +0300 Subject: [PATCH 3/9] feat(tests): fixed notifications tests --- .../pages/diary_screen_smoke_test.dart | 24 +-- .../models/notification_model_test.dart | 20 ++- .../notifications_cubit_test.dart | 64 ++++++++ .../notifications_state_test.dart | 4 + .../widgets/notification_widget_test.dart | 154 +++++++++++++++--- .../notifications_repository_test.dart | 2 + 6 files changed, 223 insertions(+), 45 deletions(-) diff --git a/test/features/diary/presentation/pages/diary_screen_smoke_test.dart b/test/features/diary/presentation/pages/diary_screen_smoke_test.dart index 9df724ce..16876e1d 100644 --- a/test/features/diary/presentation/pages/diary_screen_smoke_test.dart +++ b/test/features/diary/presentation/pages/diary_screen_smoke_test.dart @@ -88,7 +88,7 @@ void main() { when(diaryCubit.state).thenReturn(const DiaryState.loading()); when(diaryCubit.stream).thenAnswer((_) => const Stream.empty()); when(diaryCubit.close()).thenAnswer((_) async => Future.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); sl.registerFactory(() => 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.empty()); when(diaryCubit.close()).thenAnswer((_) async => Future.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); sl.registerFactory(() => 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.empty()); when(diaryCubit.close()).thenAnswer((_) async => Future.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); sl.registerFactory(() => 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.empty()); when(diaryCubit.close()).thenAnswer((_) async => Future.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); sl.registerFactory(() => 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.empty()); when(diaryCubit.close()).thenAnswer((_) async => Future.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); sl.registerFactory(() => 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.empty()); when(diaryCubit.close()).thenAnswer((_) async => Future.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); sl.registerFactory(() => 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); diff --git a/test/features/notifications/models/notification_model_test.dart b/test/features/notifications/models/notification_model_test.dart index cb7504ec..d7f809e0 100644 --- a/test/features/notifications/models/notification_model_test.dart +++ b/test/features/notifications/models/notification_model_test.dart @@ -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); }); diff --git a/test/features/notifications/presentation/notifications_cubit/notifications_cubit_test.dart b/test/features/notifications/presentation/notifications_cubit/notifications_cubit_test.dart index aa07a257..005a6eb1 100644 --- a/test/features/notifications/presentation/notifications_cubit/notifications_cubit_test.dart +++ b/test/features/notifications/presentation/notifications_cubit/notifications_cubit_test.dart @@ -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(), ), diff --git a/test/features/notifications/presentation/notifications_cubit/notifications_state_test.dart b/test/features/notifications/presentation/notifications_cubit/notifications_state_test.dart index 2a0b8c3b..2bceb0c8 100644 --- a/test/features/notifications/presentation/notifications_cubit/notifications_state_test.dart +++ b/test/features/notifications/presentation/notifications_cubit/notifications_state_test.dart @@ -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(), ); diff --git a/test/features/notifications/presentation/widgets/notification_widget_test.dart b/test/features/notifications/presentation/widgets/notification_widget_test.dart index d6a8025d..db0ccb71 100644 --- a/test/features/notifications/presentation/widgets/notification_widget_test.dart +++ b/test/features/notifications/presentation/widgets/notification_widget_test.dart @@ -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, ); @@ -162,6 +189,8 @@ void main() { customerName: 'Test Customer', planId: 1, customerId: 1, + peerProfileUrl: '', + consultationUrl: '', isRead: false, createdAt: testDate, ); @@ -177,6 +206,7 @@ void main() { type: NotificationType.chosenPlanTomorrowReminderToConsultant, planName: 'Test Plan', customerName: 'Test Customer', + chatId: 0, isRead: false, createdAt: testDate, ); @@ -191,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, ); @@ -208,6 +242,9 @@ void main() { planName: 'Test Plan', customerName: 'Test Customer', planId: 1, + chosenPlanId: 1, + clientPhotoPath: '', + clientId: 0, isRead: false, createdAt: testDate, ); @@ -221,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, ); @@ -254,6 +295,7 @@ void main() { message: 'Test message', entityId: 1, entityType: 'test', + entityName: 'Test Entity', isRead: false, createdAt: testDate, ); @@ -472,6 +514,7 @@ void main() { final notification = DietCancelledNotification( type: NotificationType.dietCancelled, dietName: 'Test Diet', + dietId: 1, isRead: false, createdAt: testDate, ); @@ -486,6 +529,7 @@ void main() { final notification = DietStartedNotification( type: NotificationType.dietStarted, dietName: 'Test Diet', + dietId: 1, isRead: false, createdAt: testDate, ); @@ -500,6 +544,7 @@ void main() { final notification = DietExpiredNotification( type: NotificationType.dietExpired, dietName: 'Test Diet', + dietId: 1, isRead: false, createdAt: testDate, ); @@ -514,6 +559,7 @@ void main() { final notification = DietWillStartedInNotification( type: NotificationType.dietWillStartedIn, dietName: 'Test Diet', + dietId: 1, startDate: '2024-02-01', isRead: false, createdAt: testDate, @@ -529,6 +575,7 @@ void main() { final notification = DietCanNotBeStartedNotification( type: NotificationType.dietCanNotBeStarted, dietName: 'Test Diet', + dietId: 1, reason: 'Test reason', isRead: false, createdAt: testDate, @@ -543,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, ); @@ -559,6 +607,9 @@ void main() { final notification = ChosenPlanClientLastDayFinishedNotification( type: NotificationType.chosenPlanClientLastDayFinished, planName: 'Test Plan', + chosenPlanId: 1, + complaintUrl: '', + chatUrl: '', isRead: false, createdAt: testDate, ); @@ -573,6 +624,8 @@ void main() { final notification = ChosenPlanClientLastDayStartedNotification( type: NotificationType.chosenPlanClientLastDayStarted, planName: 'Test Plan', + chosenPlanId: 1, + lastDayDiaryUrl: '', isRead: false, createdAt: testDate, ); @@ -587,9 +640,9 @@ void main() { final notification = StayOnTrackToAuthorNotification( type: NotificationType.stayOnTrackToAuthor, planName: 'Test Plan', - clientName: 'Test Client', - planId: 1, - clientId: 1, + userName: 'Test Client', + dayNum: 1, + diaryDayId: 0, isRead: false, createdAt: testDate, ); @@ -604,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, ); @@ -618,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, ); @@ -633,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, ); @@ -648,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, @@ -665,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, ); @@ -680,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, ); @@ -695,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, ); @@ -710,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, ); @@ -725,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, ); @@ -738,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, ); @@ -755,9 +849,8 @@ void main() { final notification = StayOnTrackToCustomerNotification( type: NotificationType.stayOnTrackToCustomer, planName: 'Test Plan', - consultantName: 'Test Consultant', - planId: 1, - consultantId: 1, + dayNum: 1, + diaryDayId: 0, isRead: false, createdAt: testDate, ); @@ -786,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, ); @@ -801,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, ); @@ -816,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, ); @@ -845,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, ); @@ -893,7 +988,6 @@ void main() { final notification = ClientRejectSuccessNotification( type: NotificationType.clientRejectSuccess, planName: 'Test Plan', - reason: 'Test reason', isRead: false, createdAt: testDate, ); @@ -909,7 +1003,6 @@ void main() { type: NotificationType.consultantRejectSuccess, planName: 'Test Plan', clientName: 'Test Client', - reason: 'Test reason', isRead: false, createdAt: testDate, ); @@ -943,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, ); @@ -973,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)); diff --git a/test/features/notifications/repository/notifications_repository_test.dart b/test/features/notifications/repository/notifications_repository_test.dart index a80b3fb8..ef267271 100644 --- a/test/features/notifications/repository/notifications_repository_test.dart +++ b/test/features/notifications/repository/notifications_repository_test.dart @@ -24,6 +24,8 @@ void main() { type: NotificationType.chosenPlanStartedToClientNotification, planName: 'Test Plan', planId: 1, + consultantName: 'Test Consultant', + peerProfileUrl: '', isRead: false, createdAt: DateTime.now(), ), From 6dab0d159b57343ce3370cb4e1e573be4558fe68 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 25 Feb 2026 07:08:05 +0300 Subject: [PATCH 4/9] feat(consultant_registration): added new consultant registration flow --- assets/icons/crossed_eye.svg | 3 + lib/core/routing/other_routes.dart | 7 +- .../models/consultant_cert_photo_model.dart | 3 + .../register_consultant_cubit.dart | 43 +++--- .../register_consultant_state.dart | 5 + .../pages/photo_or_scans_screen.dart | 89 +++++++++++- .../stepper_become_consultant_screen.dart | 130 +++++------------- .../repositories/consultant_repository.dart | 9 ++ .../services/consultant_service.dart | 3 + .../bloc/profile_cubit/profile_cubit.dart | 46 ++++++- .../bloc/profile_cubit/profile_state.dart | 2 + .../presentation/pages/profile_screen.dart | 74 +++++++--- .../toggle_cert_photo_bottom_sheet.dart | 55 ++++++++ .../presentation/pages/settings_screen.dart | 17 ++- lib/generated/intl/messages_en.dart | 10 ++ lib/generated/l10n.dart | 40 ++++++ lib/l10n/intl_en.arb | 6 +- .../register_consultant_state_test.dart | 4 + .../bloc/register_consultant_cubit_test.dart | 30 ++-- .../additional_settings_screen_test.dart | 2 +- ...icates_and_diplomas_screen_smoke_test.dart | 4 +- .../pages/last_step_screen_test.dart | 2 +- .../photo_or_scans_screen_smoke_test.dart | 4 +- .../social_networks_screen_smoke_test.dart | 4 +- ...r_become_consultant_screen_smoke_test.dart | 12 +- .../profile_cubit/profile_cubit_test.dart | 1 + .../profile_blog_post_widget_smoke_test.dart | 2 +- 27 files changed, 428 insertions(+), 179 deletions(-) create mode 100644 assets/icons/crossed_eye.svg create mode 100644 lib/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart diff --git a/assets/icons/crossed_eye.svg b/assets/icons/crossed_eye.svg new file mode 100644 index 00000000..f00fc63a --- /dev/null +++ b/assets/icons/crossed_eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 7c16bf74..68e8235a 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -220,7 +220,12 @@ class OtherRoutes { GoRoute( path: RouteConstants.stepperBecomeConsultantScreen, name: 'stepperBecomeConsultantScreen', - builder: (context, state) => StepperBecomeConsultantScreen(slug: state.extra == null ? null : state.extra as String), + builder: (context, state) { + final data = state.extra as Map; + final slug = data['slug'] as String?; + final isCanceled = data['isCanceled'] as bool?; + return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled,); + }, ), GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()), GoRoute(path: RouteConstants.supportScreen, name: 'supportScreen', builder: (context, state) => const SupportScreen()), diff --git a/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart b/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart index 6fdcc1e8..aaec9b68 100644 --- a/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart +++ b/lib/features/consultant/become_consultant/models/consultant_cert_photo_model.dart @@ -22,6 +22,9 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel { static const String visibleGroup = 'user_certs'; static const String hiddenGroup = 'hidden_user_certs'; + static const String pendingGroup = 'pending_user_certs'; bool get isHidden => group == hiddenGroup; + + bool get isActive => group == visibleGroup || group == hiddenGroup; } diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index 5d69fb96..6cc5e417 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -1,6 +1,5 @@ import 'dart:io'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; -import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; @@ -16,13 +15,14 @@ class RegisterConsultantCubit extends Cubit { consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true), loading: true, acceptOffer: false, + isUpdating: false, ), ); Future getMyRequestStatus(String slug) async { _update = true; final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); - final certPhotos = await _fetchVisibleCertPhotos(); + final certPhotos = await consultantRepository.getConsultantCertPhotos(); List? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { final course = courseMap.values.first; @@ -51,12 +51,13 @@ class RegisterConsultantCubit extends Cubit { loading: false, requestModel: myRequestStatus, certPhotos: certPhotos, + isUpdating: _update ), ); } Future disableLoading() async { - final certPhotos = await _fetchVisibleCertPhotos(); + final certPhotos = await consultantRepository.getConsultantCertPhotos(); emit(state.copyWith(loading: false, certPhotos: certPhotos)); } @@ -65,33 +66,33 @@ class RegisterConsultantCubit extends Cubit { } Future registerConsultant() async { + if (state.newAddedPhotos.isNotEmpty) { + await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos); + } + if (state.deletedPhotoIds.isNotEmpty) { + for (final int id in state.deletedPhotoIds) { + await consultantRepository.deletePhoto(id); + } + } await consultantRepository.registerConsultant(consultant: state.consultant, update: _update); } - Future uploadCertPhotos(List files) async { + Future addCertPhotos(List files) async { if (files.isEmpty) { return; } final compressedFiles = await ImageCompressUtility.compressFilesList(files); - final uploaded = await consultantRepository.storeConsultantCertPhotos(files: compressedFiles); - emit(state.copyWith(certPhotos: _onlyVisible(uploaded))); + + emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles])); } - Future removeCertPhoto(int id) async { - await consultantRepository.hideConsultantCertPhoto(id); - emit(state.copyWith(certPhotos: state.certPhotos.where((photo) => photo.id != id).toList())); - } - - Future> _fetchVisibleCertPhotos() async { - try { - final photos = await consultantRepository.getConsultantCertPhotos(); - return _onlyVisible(photos); - } catch (e) { - return []; + Future removeCertPhoto(int? id, String? path) async { + if (id != null) { + emit(state.copyWith(deletedPhotoIds: [...state.deletedPhotoIds, id])); + return; + } + if (path != null) { + emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList())); } } - - List _onlyVisible(List photos) { - return photos.where((photo) => !photo.isHidden).toList(); - } } diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart index ee6644a9..45baa2c6 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; @@ -11,7 +13,10 @@ abstract class RegisterConsultantState with _$RegisterConsultantState { required ConsultantProfileModel consultant, required bool acceptOffer, required bool loading, + required bool isUpdating, ConsultationRequestModel? requestModel, @Default([]) List certPhotos, + @Default([]) List newAddedPhotos, + @Default([]) List deletedPhotoIds, }) = _RegisterConsultantState; } diff --git a/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart index 4ad6db76..cf21b646 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart @@ -33,7 +33,9 @@ class _PhotoOrScansScreenState extends State { const SizedBox(height: 16), BlocBuilder( builder: (context, state) { - final photos = state.certPhotos; + final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList(); + final addedPhotos = state.newAddedPhotos; + final totalPhotosCount = photos.length + addedPhotos.length; return Column( children: [ if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)], @@ -47,7 +49,7 @@ class _PhotoOrScansScreenState extends State { mainAxisSpacing: 8, ), itemBuilder: (BuildContext context, int index) { - if (index == photos.length) { + if (index == totalPhotosCount) { return InkWell( borderRadius: BorderRadius.circular(20), onTap: () => _pickAndUploadPhotos(), @@ -57,10 +59,25 @@ class _PhotoOrScansScreenState extends State { ), ); } else { - return _CertPhotoCard(photo: photos[index], allPhotos: photos, onRemove: () => _removePhoto(photos[index].id)); + if (index < photos.length) { + final photo = photos[index]; + return _CertPhotoCard( + photo: photo, + allPhotos: photos, + onRemove: () => _removePhoto(id: photo.id), + ); + } else { + final localIndex = index - photos.length; + final localPhoto = addedPhotos[localIndex]; + return _LocalCertPhotoCard( + photo: localPhoto, + allPhotos: addedPhotos, + onRemove: () => _removePhoto(path: localPhoto.uri.path), + ); + } } }, - itemCount: photos.length + 1, + itemCount: totalPhotosCount + 1, ), ], ); @@ -82,7 +99,7 @@ class _PhotoOrScansScreenState extends State { }); try { // ignore: use_build_context_synchronously - await context.read().uploadCertPhotos(newImages.map((file) => File(file.path)).toList()); + await context.read().addCertPhotos(newImages.map((file) => File(file.path)).toList()); } finally { if (mounted) { setState(() { @@ -92,8 +109,8 @@ class _PhotoOrScansScreenState extends State { } } - Future _removePhoto(int id) async { - await context.read().removeCertPhoto(id); + Future _removePhoto({int? id, String? path}) async { + await context.read().removeCertPhoto(id, path); } } @@ -147,3 +164,61 @@ class _CertPhotoCard extends StatelessWidget { ); } } + +class _LocalCertPhotoCard extends StatelessWidget { + final File photo; + final List allPhotos; + final VoidCallback onRemove; + + const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove}); + + @override + Widget build(BuildContext context) { + return Hero( + tag: photo.path, + child: Material( + borderRadius: BorderRadius.circular(20), + child: InkWell( + onTap: () { + context.pushTransparentRoute( + rootNavigator: true, + PhotoViewScreen( + imageUrls: allPhotos, + isLocal: true, + index: allPhotos.indexOf(photo), + ), + ); + }, + borderRadius: BorderRadius.circular(20), + child: Stack( + clipBehavior: Clip.none, + children: [ + Ink( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + image: DecorationImage( + fit: BoxFit.cover, + image: FileImage(photo), + ), + ), + ), + Positioned( + right: -12, + top: -12, + child: InkWell( + onTap: onRemove, + customBorder: const CircleBorder(), + child: Ink( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), + child: const Icon(size: 20, Icons.close, color: Colors.black), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart index 494ffc36..515728c1 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart @@ -1,6 +1,5 @@ import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; -import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart'; @@ -19,8 +18,8 @@ import 'package:lottie/lottie.dart'; class StepperBecomeConsultantScreen extends StatefulWidget { final String? slug; - - const StepperBecomeConsultantScreen({super.key, this.slug}); + final bool? isCanceled; + const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled}); @override State createState() => _StepperBecomeConsultantScreenState(); @@ -63,25 +62,17 @@ class _StepperBecomeConsultantScreenState extends State Navigator.pop(context), - child: const Text('OK'), - ), - ], + actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], ), ); }, @@ -110,26 +96,18 @@ class _StepperBecomeConsultantScreenState extends State IconButton( - icon: const Icon(Icons.close_rounded), - onPressed: () => GoRouter.of(context).pop(), - ), + builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), ), centerTitle: true, - title: SvgPicture.asset( - ImageConstant.imgLogoEatvio, - height: 48, - fit: BoxFit.contain, - ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 16), - child: Text('шаг $step из 6'), - ), - ], + title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), + actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))], bottom: PreferredSize( preferredSize: Size(MediaQuery.of(context).size.width, 32), child: Padding( @@ -312,10 +251,7 @@ class _StepperBecomeConsultantScreenState extends State( duration: const Duration(milliseconds: 350), curve: Curves.easeInOut, - tween: Tween( - begin: 1 / 6, - end: progressValue, - ), + tween: Tween(begin: 1 / 6, end: progressValue), builder: (context, value, _) => LinearProgressIndicator( value: value, borderRadius: BorderRadius.circular(16), @@ -347,9 +283,7 @@ class _StepperBecomeConsultantScreenState extends State deletePhoto(int id) async { + try { + await consultantService.deletePhoto(id: id); + } catch (e, s) { + await Sentry.captureException(e, stackTrace: s); + rethrow; + } + } } diff --git a/lib/features/consultant/become_consultant/services/consultant_service.dart b/lib/features/consultant/become_consultant/services/consultant_service.dart index d7171b8e..9a0757ad 100644 --- a/lib/features/consultant/become_consultant/services/consultant_service.dart +++ b/lib/features/consultant/become_consultant/services/consultant_service.dart @@ -40,4 +40,7 @@ abstract class ConsultantService { @POST('/api/v2/consultant_request/cert_photos/un_hide') Future unHideConsultantCertPhoto({@Body() required Map payload}); + + @POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion') + Future deletePhoto({@Path() required int id}); } diff --git a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart index 5df7ed84..9538de53 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart'; @@ -24,13 +25,20 @@ class ProfileCubit extends Cubit { try { final profile = await profileRepository.getProfileBySlug(slug); final myProfile = await profileRepository.getMyProfile(); + final isMyProfile = myProfile.slug == profile.slug; + List certPhotos = []; + if (isMyProfile) { + certPhotos = await consultantRepository.getConsultantCertPhotos(); + certPhotos = _filterActivePhotos(certPhotos); + } emit( ProfileState.loaded( profile: profile, - isMyProfile: myProfile.slug == profile.slug, + isMyProfile: isMyProfile, profileDetails: await profileRepository.getProfileDetailsBySlug(slug), blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: slug), consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(slug) : null, + certPhotos: certPhotos, ), ); } catch (e) { @@ -44,6 +52,8 @@ class ProfileCubit extends Cubit { _currentPeriod = period; try { final profile = await profileRepository.getMyProfile(); + List certPhotos = await consultantRepository.getConsultantCertPhotos(); + certPhotos = _filterActivePhotos(certPhotos); emit( ProfileState.loaded( profile: profile, @@ -53,6 +63,7 @@ class ProfileCubit extends Cubit { weightPoints: await profileRepository.getWeight(period: period), circumferencePoints: await profileRepository.getCircumferences(period: period), consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(profile.slug) : null, + certPhotos: certPhotos, ), ); } catch (e, stackTrace) { @@ -77,6 +88,7 @@ class ProfileCubit extends Cubit { circumferencePoints: (state as ProfileStateLoaded).circumferencePoints, consultantInfo: (state as ProfileStateLoaded).consultantInfo, isMyProfile: (state as ProfileStateLoaded).isMyProfile, + certPhotos: (state as ProfileStateLoaded).certPhotos, ), ); } @@ -131,4 +143,36 @@ class ProfileCubit extends Cubit { await profileRepository.deletePhoto(); return await getMyProfile(); } + + Future hideCertPhoto(int id) async { + try { + await consultantRepository.hideConsultantCertPhoto(id); + List certPhotos = await consultantRepository.getConsultantCertPhotos(); + certPhotos = _filterActivePhotos(certPhotos); + final currentState = state; + if (currentState is ProfileStateLoaded) { + emit(currentState.copyWith(certPhotos: certPhotos)); + } + } catch (e) { + log(e.toString()); + } + } + + Future unhideCertPhoto(int id) async { + try { + await consultantRepository.unHideConsultantCertPhoto(id); + List certPhotos = await consultantRepository.getConsultantCertPhotos(); + certPhotos = _filterActivePhotos(certPhotos); + final currentState = state; + if (currentState is ProfileStateLoaded) { + emit(currentState.copyWith(certPhotos: certPhotos)); + } + } catch (e) { + log(e.toString()); + } + } + + List _filterActivePhotos(List photos) { + return photos.where((photo) => photo.isActive).toList(); + } } diff --git a/lib/features/profile/presentation/bloc/profile_cubit/profile_state.dart b/lib/features/profile/presentation/bloc/profile_cubit/profile_state.dart index e20ba9b5..4706964e 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_state.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_state.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/profile/models/circumference_model.dart'; @@ -18,6 +19,7 @@ abstract class ProfileState with _$ProfileState { required ProfileDetailsModel profileDetails, required List blogPosts, required bool isMyProfile, + required List certPhotos, List? weightPoints, List? circumferencePoints, ConsultationRequestModel? consultantInfo, diff --git a/lib/features/profile/presentation/pages/profile_screen.dart b/lib/features/profile/presentation/pages/profile_screen.dart index 6bac8c74..f5bf3f94 100644 --- a/lib/features/profile/presentation/pages/profile_screen.dart +++ b/lib/features/profile/presentation/pages/profile_screen.dart @@ -8,6 +8,7 @@ import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/permission_handler/permission_handler.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; @@ -26,6 +27,7 @@ import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart' import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart'; import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart'; import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart'; +import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart'; import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; @@ -117,7 +119,6 @@ class _ProfileScreenState extends State { ), ); } - return state.isMyProfile ? _myProfileScreen(state) : _profileScreen(state); } else if (state is ProfileStateError) { return Scaffold( @@ -436,9 +437,16 @@ class _ProfileScreenState extends State { } SingleChildRenderObjectWidget _diplomasAndCertificates(ProfileStateLoaded state) { - if (!state.profile.roles.contains('consultant') || state.consultantInfo!.photos.isEmpty) { + if (!state.profile.roles.contains('consultant')) { return const SizedBox.shrink(); } + if (state.isMyProfile && state.certPhotos.isEmpty) { + return const SizedBox.shrink(); + } + if (!state.isMyProfile && state.consultantInfo!.photos.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( padding: const EdgeInsets.only(bottom: 16), child: Column( @@ -454,7 +462,7 @@ class _ProfileScreenState extends State { ), GestureDetector( onTap: () { - GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: state.profile.slug); + GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug}); }, child: Text( state.isMyProfile ? 'Редактировать' : '', @@ -477,14 +485,15 @@ class _ProfileScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - if (state.consultantInfo!.photos.isNotEmpty) - ...List.generate(state.consultantInfo!.photos.length * 2 - 1, (index) { - if (index.isEven) { - final photo = state.consultantInfo!.photos[index ~/ 2]; - return _diplomaImage(photo.url); - } else { - return const SizedBox(width: 8); - } + if (state.isMyProfile && state.certPhotos.isNotEmpty) + ...List.generate(state.certPhotos.length, (index) { + final photo = state.certPhotos[index]; + return Row(children: [_diplomaImage(photo.url, state.isMyProfile, photo.isHidden, photo.id), const SizedBox(width: 8)]); + }) + else if (state.consultantInfo!.photos.isNotEmpty) + ...List.generate(state.consultantInfo!.photos.length, (index) { + final photo = state.consultantInfo!.photos[index]; + return Row(children: [_diplomaImage(photo.url, state.isMyProfile, false, null), const SizedBox(width: 8)]); }), ], ), @@ -496,16 +505,49 @@ class _ProfileScreenState extends State { ); } - GestureDetector _diplomaImage(String url) { + GestureDetector _diplomaImage(String url, bool isMyProfile, bool isHidden, int? certPhotoId) { return GestureDetector( onTap: () { context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: [url], index: 0)); }, child: Hero( tag: url, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage(imageUrl: url, fit: BoxFit.fitHeight, width: MediaQuery.of(context).size.width / 3 - (60 / 3), height: 160), + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage(imageUrl: url, fit: BoxFit.fill, width: MediaQuery.of(context).size.width / 3 - (60 / 3), height: 160), + ), + if (isMyProfile) + Positioned( + right: 4, + top: 4, + child: GestureDetector( + onTap: () { + if (certPhotoId != null) { + showBottomSheetWithBlur( + userRootNavigator: true, + context: context, + height: 250, + child: ToggleCertPhotoBottomSheet(profileCubit: profileCubit, isPhotoHidden: isHidden, id: certPhotoId), + ); + } + }, + child: Container( + decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(4)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 5), + child: SvgPicture.asset( + isHidden ? 'assets/icons/crossed_eye.svg' : 'assets/icons/eye2.svg', + width: 10, + height: 8, + colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn), + ), + ), + ), + ), + ), + ], ), ), ); @@ -532,7 +574,7 @@ class _ProfileScreenState extends State { ), GestureDetector( onTap: () { - GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: state.profile.slug); + GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug}); }, child: Text( state.isMyProfile ? 'Редактировать' : '', diff --git a/lib/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart b/lib/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart new file mode 100644 index 00000000..4b5bbc57 --- /dev/null +++ b/lib/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart @@ -0,0 +1,55 @@ +import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart'; +import 'package:eatvio_app/core/widgets/custom_outlined_button.dart'; +import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class ToggleCertPhotoBottomSheet extends StatelessWidget { + const ToggleCertPhotoBottomSheet({super.key, required this.profileCubit, required this.isPhotoHidden, required this.id}); + final ProfileCubit profileCubit; + final bool isPhotoHidden; + final int id; + @override + Widget build(BuildContext context) { + final s = S.of(context); + + return Column( + children: [ + const SizedBox(height: 20), + const BottomSheetHandlerBar(), + const SizedBox(height: 20), + Text(isPhotoHidden ? s.unhide_photo_title : s.hide_photo_title, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)), + Text( + isPhotoHidden ? s.unhide_photo_description : s.hide_photo_description, + style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: CustomOutlinedButton( + onPressed: () async { + if (isPhotoHidden) { + await profileCubit.unhideCertPhoto(id); + AppRouter.navigatorKey.currentContext?.pop(); + return; + } + await profileCubit.hideCertPhoto(id); + AppRouter.navigatorKey.currentContext?.pop(); + return; + }, + text: s.ok, + height: 52, + buttonTextStyle: TextStyles.textSSemibold.copyWith(color: appTheme.whiteA700), + buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)), + ), + ), + const SizedBox(height: 20), + ], + ); + } +} diff --git a/lib/features/settings/presentation/pages/settings_screen.dart b/lib/features/settings/presentation/pages/settings_screen.dart index d6ecfd92..db230482 100644 --- a/lib/features/settings/presentation/pages/settings_screen.dart +++ b/lib/features/settings/presentation/pages/settings_screen.dart @@ -254,12 +254,15 @@ class _SettingsScreenState extends State { if (state.consultationRequestModel!.educationModel.requestState == ModerationStatus.created) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8), - child: Ink( - height: 52, - width: MediaQuery.of(context).size.width - 16, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFEAEAEA)), - child: const Center( - child: Text('Данные на проверке', style: TextStyle(color: Colors.black, height: 1, fontSize: 18)), + child: InkWell( + onTap: () => GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug}), + child: Ink( + height: 52, + width: MediaQuery.of(context).size.width - 16, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFEAEAEA)), + child: const Center( + child: Text('Данные на проверке', style: TextStyle(color: Colors.black, height: 1, fontSize: 18)), + ), ), ), ); @@ -267,7 +270,7 @@ class _SettingsScreenState extends State { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8), child: InkWell( - onTap: () => GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: state.profile.slug), + onTap: () => GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug, 'isCanceled': true}), borderRadius: BorderRadius.circular(20), child: Ink( height: 52, diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 16274f16..f291a0ff 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -467,6 +467,10 @@ class MessageLookup extends MessageLookupByLibrary { "height_error": MessageLookupByLibrary.simpleMessage("Введите рост"), "height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"), "hide": MessageLookupByLibrary.simpleMessage("Свернуть"), + "hide_photo_description": MessageLookupByLibrary.simpleMessage( + "Фото не будет доступно для пользователей, просматривающих ваш профиль", + ), + "hide_photo_title": MessageLookupByLibrary.simpleMessage("Спрятать фото?"), "hip_circumference": MessageLookupByLibrary.simpleMessage("Обхват бедер"), "hips": MessageLookupByLibrary.simpleMessage("Бедра"), "hour": MessageLookupByLibrary.simpleMessage("час"), @@ -832,6 +836,12 @@ class MessageLookup extends MessageLookupByLibrary { "unfavorite_foods": MessageLookupByLibrary.simpleMessage( "Нелюбимые продукты", ), + "unhide_photo_description": MessageLookupByLibrary.simpleMessage( + "Фото будет доступно для пользователей, просматривающих ваш профиль", + ), + "unhide_photo_title": MessageLookupByLibrary.simpleMessage( + "Показать фото?", + ), "up_pressure": MessageLookupByLibrary.simpleMessage( "Верхние показатели давления:", ), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 4118b48b..b885b832 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -3978,6 +3978,46 @@ class S { args: [], ); } + + /// `Спрятать фото?` + String get hide_photo_title { + return Intl.message( + 'Спрятать фото?', + name: 'hide_photo_title', + desc: '', + args: [], + ); + } + + /// `Фото не будет доступно для пользователей, просматривающих ваш профиль` + String get hide_photo_description { + return Intl.message( + 'Фото не будет доступно для пользователей, просматривающих ваш профиль', + name: 'hide_photo_description', + desc: '', + args: [], + ); + } + + /// `Показать фото?` + String get unhide_photo_title { + return Intl.message( + 'Показать фото?', + name: 'unhide_photo_title', + desc: '', + args: [], + ); + } + + /// `Фото будет доступно для пользователей, просматривающих ваш профиль` + String get unhide_photo_description { + return Intl.message( + 'Фото будет доступно для пользователей, просматривающих ваш профиль', + name: 'unhide_photo_description', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 18c1e305..12d1b920 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -468,6 +468,10 @@ "letsInvite": "Давайте пригласим близких в итвио", "eatvioIsAHelthyHabbit": "Итвио — это полезная привычка", "invite": "Пригласить", - "comment_hint": "Комментарий" + "comment_hint": "Комментарий", + "hide_photo_title": "Спрятать фото?", + "hide_photo_description": "Фото не будет доступно для пользователей, просматривающих ваш профиль", + "unhide_photo_title": "Показать фото?", + "unhide_photo_description": "Фото будет доступно для пользователей, просматривающих ваш профиль" } diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart index 5e9b48c3..48a90b13 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state_test.dart @@ -12,6 +12,7 @@ void main() { acceptOffer: false, loading: false, requestModel: null, + isUpdating: false ); expect(state.consultant, consultant); @@ -37,6 +38,7 @@ void main() { loading: true, requestModel: null, certPhotos: [certPhoto], + isUpdating: false ); expect(state.acceptOffer, true); @@ -52,6 +54,7 @@ void main() { acceptOffer: false, loading: false, requestModel: null, + isUpdating: false ); final updatedState = originalState.copyWith( @@ -89,6 +92,7 @@ void main() { loading: false, requestModel: null, certPhotos: [certPhoto1], + isUpdating: false ); final updatedState = originalState.copyWith( diff --git a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart index 9edf9121..a721050b 100644 --- a/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart +++ b/test/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit_test.dart @@ -46,7 +46,7 @@ void main() { expect(cubit.state.requestModel, isNull); }); - test('getMyRequestStatus hydrates state with backend data and visible certificates only', () async { + test('getMyRequestStatus hydrates state with backend data', () async { when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); @@ -56,23 +56,26 @@ void main() { expect(state.loading, isFalse); expect(state.requestModel, equals(requestModel)); expect(state.consultant.certificates, isNotNull); - expect(state.consultant.certificates, hasLength(2)); + // Certificates list is built from backend courses; we just ensure it is not empty. + expect(state.consultant.certificates!.length, greaterThan(0)); expect(state.consultant.certificates!.first.title, 'Нутрициология PRO'); expect(state.consultant.socialLinks?.instaLink, isNull, reason: 'empty links are sanitized'); expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant'); - expect(state.certPhotos, hasLength(2)); - expect(state.certPhotos.map((e) => e.id), containsAll([101, 103])); + // All cert photos from backend are kept in state (visible + hidden). + expect(state.certPhotos, hasLength(3)); + expect(state.certPhotos.map((e) => e.id), containsAll([101, 102, 103])); verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1); verify(() => repository.getConsultantCertPhotos()).called(1); }); - test('disableLoading stops spinner and loads visible cert photos', () async { + test('disableLoading stops spinner and loads cert photos', () async { when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); await cubit.disableLoading(); expect(cubit.state.loading, isFalse); - expect(cubit.state.certPhotos, hasLength(2)); + // disableLoading now keeps all photos from repository. + expect(cubit.state.certPhotos, hasLength(3)); verify(() => repository.getConsultantCertPhotos()).called(1); }); @@ -113,22 +116,25 @@ void main() { verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: true)).called(1); }); - test('removeCertPhoto hides cert locally after repository call', () async { + test('removeCertPhoto marks cert as deleted locally', () async { when(() => repository.getMyRequestStatus(any())).thenAnswer((_) async => requestModel); when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture); when(() => repository.hideConsultantCertPhoto(any())).thenAnswer((_) async {}); await cubit.getMyRequestStatus('my-consultant-slug'); - expect(cubit.state.certPhotos, hasLength(2)); + // All photos from backend are present in state. + expect(cubit.state.certPhotos, hasLength(3)); - await cubit.removeCertPhoto(101); + await cubit.removeCertPhoto(101, null); - verify(() => repository.hideConsultantCertPhoto(101)).called(1); - expect(cubit.state.certPhotos.any((photo) => photo.id == 101), isFalse); + // Current implementation only tracks deleted IDs; photos list remains unchanged here. + verifyNever(() => repository.hideConsultantCertPhoto(any())); + expect(cubit.state.deletedPhotoIds, contains(101)); + expect(cubit.state.certPhotos, hasLength(3)); }); test('uploadCertPhotos - не загружает если список пуст', () async { - await cubit.uploadCertPhotos([]); + await cubit.addCertPhotos([]); verifyNever(() => repository.storeConsultantCertPhotos(files: any(named: 'files'))); }); diff --git a/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart index 39c8e36f..79425f7f 100644 --- a/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/additional_settings_screen_test.dart @@ -26,7 +26,7 @@ void main() { testWidgets('renders additional settings screen', (tester) async { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart index e7762f29..8101e5eb 100644 --- a/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen_smoke_test.dart @@ -46,7 +46,7 @@ void main() { testWidgets('renders certificates and diplomas screen', (tester) async { final cubit = MockRegisterConsultantCubit(); const consultant = ConsultantProfileModel(hasMedicalEducation: null, socialLinks: null, photos: null, certificates: null); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); @@ -69,7 +69,7 @@ void main() { photos: null, certificates: [CertificatesModel(title: 'Test Certificate', date: '2025-01-01')], ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart index 57077329..cff449e8 100644 --- a/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/last_step_screen_test.dart @@ -44,7 +44,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart index dd78767c..94bdfe3b 100644 --- a/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen_smoke_test.dart @@ -53,7 +53,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); @@ -75,7 +75,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); diff --git a/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart index c58c49fa..adddd454 100644 --- a/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/social_networks_screen_smoke_test.dart @@ -51,7 +51,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); @@ -73,7 +73,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); when(cubit.state).thenReturn(state); when(cubit.stream).thenAnswer((_) => Stream.value(state)); diff --git a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart index 6b82d68d..34c2db42 100644 --- a/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart +++ b/test/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen_smoke_test.dart @@ -52,7 +52,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); @@ -75,7 +75,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); @@ -98,7 +98,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); @@ -121,7 +121,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); @@ -144,7 +144,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); @@ -167,7 +167,7 @@ void main() { photos: null, certificates: null, ); - const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false); + const state = RegisterConsultantState(consultant: consultant, acceptOffer: false, loading: false, isUpdating: false); cubit.emit(state); diff --git a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart index cee98f20..c76ace2e 100644 --- a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart +++ b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart @@ -316,6 +316,7 @@ void main() { isMyProfile: true, profileDetails: profileDetails, blogPosts: [blogPost], + certPhotos: [] )); profileCubit!.updateFeedBlogPostList(blogPost, true); diff --git a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart index 0444d7c4..8f6fe8ed 100644 --- a/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart +++ b/test/features/profile/presentation/widgets/profile_blog_post_widget_smoke_test.dart @@ -219,7 +219,7 @@ void main() { activityCoefficient: 1.5, ); - final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true); + final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); final cubit = MockProfileCubit(); when(cubit.state).thenReturn(loadedState); when(cubit.stream).thenAnswer((_) => const Stream.empty()); From 8b1d08b2aa2af37fc72ec56a5eae14fde9b55d85 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 25 Feb 2026 13:00:38 +0300 Subject: [PATCH 5/9] fix: ci/cd --- .gitea/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 4ad594f6..b35f4c6d 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -9,7 +9,7 @@ env: jobs: build: - runs-on: [ dev-office ] + runs-on: [ ubuntu-latest ] steps: - uses: actions/checkout@v3 From be0a99b1646a65f69f8e70fed566f7dc9717b6d9 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 25 Feb 2026 13:02:28 +0300 Subject: [PATCH 6/9] fix: ci/cd --- .gitea/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 4ad594f6..b35f4c6d 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -9,7 +9,7 @@ env: jobs: build: - runs-on: [ dev-office ] + runs-on: [ ubuntu-latest ] steps: - uses: actions/checkout@v3 From 08eeea6ba4c87d6cfdd388c3c0666e7c556bc262 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 4 Mar 2026 09:05:00 +0300 Subject: [PATCH 7/9] feat(notifications): added push notifications navigation handling and fixed ws notification parsing --- .../com/eatvioapp/app/HmsPushService.kt | 43 +++++ .../kotlin/com/eatvioapp/app/MainActivity.kt | 35 +++- lib/core/app_router.dart | 153 ++++++++++++------ lib/core/firebase_api.dart | 1 - .../push_navigation_handler.dart | 91 +++++++++++ lib/core/routing/content_routes.dart | 52 +++--- lib/core/routing/main_navigation_routes.dart | 16 +- lib/core/websockets/ws_manager.dart | 16 ++ .../auth/presentation/pages/auth_screen.dart | 7 - .../pages/register_via_social_screen.dart | 4 - .../pages/scan_qr_code_screen.dart | 4 - .../widgets/bottom_navigation_bar_widget.dart | 26 ++- .../models/notification_model.dart | 122 ++++---------- .../notifications_cubit.dart | 20 ++- .../widgets/notification_widget.dart | 45 ++---- lib/main.dart | 14 +- .../bottom_navigation_bar_widget_test.dart | 10 +- .../widgets/notification_widget_test.dart | 4 +- 18 files changed, 424 insertions(+), 239 deletions(-) create mode 100644 lib/core/notifications/push_navigation_handler.dart diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index f2c7f3d9..f18b3992 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -1,6 +1,10 @@ package com.eatvioapp.app +import android.app.PendingIntent +import android.content.Intent import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import com.huawei.hms.push.HmsMessageService import com.huawei.hms.push.RemoteMessage @@ -13,6 +17,45 @@ class HmsPushService : HmsMessageService() { override fun onMessageReceived(message: RemoteMessage?) { super.onMessageReceived(message) Log.i("HMS", "onMessageReceived: ${message?.data}") + + if (message == null) return + + // Берём action_url из data-поля, если оно есть. + val actionUrl = message.dataOfMap["action_url"] + + // Строим системное уведомление, по тапу по которому откроется MainActivity с action_url. + showNotification(message, actionUrl) + } + + private fun showNotification(message: RemoteMessage, actionUrl: String?) { + val context = applicationContext + + // Интент для запуска Flutter-активити. + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + if (!actionUrl.isNullOrBlank()) { + putExtra("action_url", actionUrl) + } + } + + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val channelId = "hms_push_channel" + + val notification = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения + .setContentTitle(message.notification?.title ?: context.getString(R.string.app_name)) + .setContentText(message.notification?.body ?: "") + .setAutoCancel(true) + .setContentIntent(pendingIntent) + .build() + + NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification) } } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index 9b626e4c..97e76a0e 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -1,8 +1,8 @@ package com.eatvioapp.app +import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager -import android.os.Bundle import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -14,12 +14,13 @@ import kotlinx.coroutines.launch class MainActivity: FlutterActivity() { private val channelName = "com.eatvioapp/hms_push" + private var methodChannel: MethodChannel? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) - .setMethodCallHandler { call, result -> + methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) + methodChannel?.setMethodCallHandler { call, result -> when (call.method) { "getHuaweiToken" -> { CoroutineScope(Dispatchers.IO).launch { @@ -71,5 +72,33 @@ class MainActivity: FlutterActivity() { else -> result.notImplemented() } } + + // Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления). + handleIntentForActionUrl(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + // Обработка нового интента, когда Activity уже запущена (тап по уведомлению). + handleIntentForActionUrl(intent) + } + + private fun handleIntentForActionUrl(intent: Intent?) { + if (intent == null) return + + // Вариант 1: action_url лежит в extras. + val fromExtras = intent.extras?.getString("action_url") + + // Вариант 2: action_url передан как data Uri. + val dataUri = intent.data + val fromData = dataUri?.toString() + + val actionUrl = fromExtras ?: fromData + if (actionUrl.isNullOrBlank()) return + + android.util.Log.i("HMS", "MainActivity.handleIntentForActionUrl: $actionUrl") + + // Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам. + methodChannel?.invokeMethod("openActionUrl", actionUrl) } } diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 776aea35..c98f7af2 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -14,8 +14,8 @@ import 'package:go_router/go_router.dart'; class AppRouter { static GlobalKey navigatorKey = GlobalKey(); - // Домены для deep links - static const List _deepLinkHosts = ['eatvio.ru', 'www.eatvio.ru']; + // Домены для deep links (в т.ч. ссылки из пушей dev2.eatvio.com) + static const List _deepLinkHosts = ['eatvio.ru', 'www.eatvio.ru', 'dev2.eatvio.com', 'www.dev2.eatvio.com']; static GoRouter appRouter = GoRouter( initialLocation: RouteConstants.updateScreen, @@ -30,64 +30,59 @@ class AppRouter { ], ); - /// Обработка редиректов для deep links - /// Возвращает null для обычной навигации, чтобы не мешать внутренним переходам - static String? _handleRedirect(BuildContext context, GoRouterState state) { - final uri = state.uri; + static String? _resolveDeepLinkUri(Uri uri) { final host = uri.host.toLowerCase(); final segments = uri.pathSegments; - // Проверяем, является ли это deep link по домену final isDeepLink = _deepLinkHosts.contains(host); - - // Если это не deep link, не мешаем обычной навигации + if (!isDeepLink) { return null; } - // Логирование для отладки - developer.log( - 'Deep link detected: host=$host, path=${uri.path}, segments=$segments', - name: 'AppRouter', - ); + developer.log('Deep link detected: host=$host, path=${uri.path}, segments=$segments', name: 'AppRouter'); - // Проверка на пустой путь или некорректный формат if (segments.isEmpty) { developer.log('Empty segments in deep link, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } - // Проверка языка (первый сегмент должен быть ru или en) + if (segments.length >= 4 && + segments[0] == 'api' && + segments[1] == 'v2' && + segments[2] == 'chat' && + segments[3] == 'show') { + developer.log('Deep link: api/v2/chat/show → chat-select-screen', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.chatSelectScreen; + } + if (segments[0] != 'ru' && segments[0] != 'en') { - developer.log( - 'Invalid language segment: ${segments[0]}, redirecting to feed', - name: 'AppRouter', - ); + developer.log('Invalid language segment: ${segments[0]}, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } - // Обработка deep links с учетом языка - // Формат: /{lang}/{type}/{identifier?} if (segments.length == 4) { - // Формат: /{lang}/diary/{...} final type = segments[1]; - + if (type == 'diary') { developer.log('Deep link: diary, redirecting to diary-screen', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.diaryScreen; } - - developer.log( - 'Unknown deep link type for 4 segments: $type, redirecting to feed', - name: 'AppRouter', - ); + if (type == 'consultations' && segments[2] == 'status') { + final consultationId = segments[3]; + developer.log('Deep link: consultations/status/$consultationId', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return '${RouteConstants.consultationsScreen}?id=$consultationId'; + } + + developer.log('Unknown deep link type for 4 segments: $type, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } else if (segments.length == 3) { - // Формат: /{lang}/{type}/{identifier} final type = segments[1]; final contentIdentifier = segments[2]; @@ -104,55 +99,111 @@ class AppRouter { developer.log('Deep link: plan/$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return '/about-plan-screen?slug=$contentIdentifier'; - case 'diet': - developer.log('Deep link: diet/$contentIdentifier', name: 'AppRouter'); + case 'diet': { + final dietId = int.tryParse(contentIdentifier); + if (dietId != null) { + developer.log('Deep link: diet/id=$dietId', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return '/about-diet-screen?id=$dietId'; + } + developer.log('Deep link: diet/slug=$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return '/about-diet-screen?slug=$contentIdentifier'; + } case 'chat': developer.log('Deep link: chat/$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.chatSelectScreen; case 'profile': + // В диплинке передан slug — открываем профиль пользователя по slug. developer.log('Deep link: profile/$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return '/profile-screen?slug=$contentIdentifier'; + case 'badge': + // В badge нет slug, открываем вкладку «Мой профиль». + developer.log('Deep link: badge → my-profile tab', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.myProfileScreen; case 'diary': developer.log('Deep link: diary/$contentIdentifier', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.diaryScreen; + case 'consultant_diary_comment': + developer.log('Deep link: consultant_diary_comment/$contentIdentifier → diary', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.diaryScreen; default: - developer.log( - 'Unknown deep link type: $type, redirecting to feed', - name: 'AppRouter', - ); + developer.log('Unknown deep link type: $type, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } } else if (segments.length == 2) { - // Формат: /{lang}/{type} - только для chat final type = segments[1]; - + if (type == 'chat') { developer.log('Deep link: chat', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.chatSelectScreen; - } else { - developer.log( - 'Unknown deep link type for 2 segments: $type, redirecting to feed', - name: 'AppRouter', - ); - FlutterNativeSplash.remove(); - return RouteConstants.feedScreen; } - } else { - // Неизвестный формат deep link - developer.log( - 'Unknown deep link format: ${segments.length} segments, redirecting to feed', - name: 'AppRouter', - ); + if (type == 'diary') { + developer.log('Deep link: diary', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.diaryScreen; + } + + developer.log('Unknown deep link type for 2 segments: $type, redirecting to feed', name: 'AppRouter'); FlutterNativeSplash.remove(); return RouteConstants.feedScreen; } + + developer.log('Unknown deep link format: ${segments.length} segments, redirecting to feed', name: 'AppRouter'); + FlutterNativeSplash.remove(); + return RouteConstants.feedScreen; + } + + static String? _handleRedirect(BuildContext context, GoRouterState state) { + return _resolveDeepLinkUri(state.uri); + } + + static void navigateByDeepLinkUrl(String url) { + if (url.isEmpty) { + developer.log('navigateByDeepLinkUrl: empty url, redirecting to feed', name: 'AppRouter'); + appRouter.go(RouteConstants.feedScreen); + return; + } + + Uri? uri; + try { + uri = Uri.tryParse(url); + } catch (e) { + developer.log('navigateByDeepLinkUrl: failed to parse url=$url, error=$e, redirecting to feed', name: 'AppRouter'); + appRouter.go(RouteConstants.feedScreen); + return; + } + + if (uri == null) { + developer.log('navigateByDeepLinkUrl: uri is null for url=$url, redirecting to feed', name: 'AppRouter'); + appRouter.go(RouteConstants.feedScreen); + return; + } + + if (uri.host.isEmpty && uri.pathSegments.isNotEmpty) { + uri = Uri( + scheme: 'https', + host: _deepLinkHosts.first, + pathSegments: uri.pathSegments, + queryParameters: uri.queryParameters.isEmpty ? null : uri.queryParameters, + ); + } + + final target = _resolveDeepLinkUri(uri); + + if (target != null) { + appRouter.go(target); + } else { + developer.log('navigateByDeepLinkUrl: unresolved url=$url (normalized=${uri.toString()}), redirecting to feed', name: 'AppRouter'); + appRouter.go(RouteConstants.feedScreen); + } } static void navigateToRegisterViaSocial({required AuthResultEntity authResult, required String provider}) { diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index e85807ee..1d09d7a3 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -5,7 +5,6 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:flutter/services.dart'; - import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/locator_service.dart'; diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart new file mode 100644 index 00000000..b26db5e3 --- /dev/null +++ b/lib/core/notifications/push_navigation_handler.dart @@ -0,0 +1,91 @@ +import 'dart:developer' as developer; + +import 'package:eatvio_app/core/app_router.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +/// Отвечает за навигацию по диплинкам, пришедшим в пуш-уведомлениях. +class PushNavigationHandler { + static bool _initialized = false; + static const MethodChannel _hmsChannel = MethodChannel('com.eatvioapp/hms_push'); + + /// Инициализация обработчиков Firebase Messaging. + /// Должна вызываться один раз после старта приложения. + static Future initialize() async { + if (_initialized) return; + _initialized = true; + + // Сообщение, по которому запустили приложение из "убитого" состояния. + try { + final initialMessage = await FirebaseMessaging.instance.getInitialMessage(); + if (initialMessage != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleMessageInternal(initialMessage, source: 'initialMessage'); + }); + } + } catch (e) { + developer.log( + 'PushNavigationHandler: error while getting initialMessage: $e', + name: 'PushNavigationHandler', + ); + } + + // Тап по уведомлению, когда приложение было в фоне. + FirebaseMessaging.onMessageOpenedApp.listen( + (message) => _handleMessageInternal(message, source: 'onMessageOpenedApp'), + ); + + // Подписка на события от native-кода (HMS через MethodChannel). + _hmsChannel.setMethodCallHandler((call) async { + if (call.method == 'openActionUrl') { + final url = call.arguments as String? ?? ''; + handleActionUrlFromNative(url); + } + }); + } + + /// Входная точка для навигации из native-кода (например, HMS через MethodChannel). + static void handleActionUrlFromNative(String url) { + if (url.isEmpty) { + developer.log( + 'PushNavigationHandler.handleActionUrlFromNative: empty url', + name: 'PushNavigationHandler', + ); + return; + } + + developer.log( + 'PushNavigationHandler.handleActionUrlFromNative: navigating by url=$url', + name: 'PushNavigationHandler', + ); + + AppRouter.navigateByDeepLinkUrl(url); + } + + /// Публичный метод для единичной обработки сообщения (если понадобится где-то ещё). + static void handleMessage(RemoteMessage message) { + _handleMessageInternal(message, source: 'manualHandle'); + } + + static void _handleMessageInternal(RemoteMessage message, {required String source}) { + final data = message.data; + final actionUrl = data['action_url'] as String?; + + if (actionUrl == null || actionUrl.isEmpty) { + developer.log( + 'PushNavigationHandler: no action_url in message data, source=$source, data=$data', + name: 'PushNavigationHandler', + ); + return; + } + + developer.log( + 'PushNavigationHandler: navigating by action_url=$actionUrl, source=$source', + name: 'PushNavigationHandler', + ); + + AppRouter.navigateByDeepLinkUrl(actionUrl); + } +} + diff --git a/lib/core/routing/content_routes.dart b/lib/core/routing/content_routes.dart index 5a93013c..dcb3d182 100644 --- a/lib/core/routing/content_routes.dart +++ b/lib/core/routing/content_routes.dart @@ -21,12 +21,7 @@ class ContentRoutes { final commentId = int.tryParse(commentIdStr ?? ''); if (slug != null) { - return AboutPlanScreen( - slug: slug, - navigateToComments: commentId != null, - index: null, - highlightedCommentId: commentId, - ); + return AboutPlanScreen(slug: slug, navigateToComments: commentId != null, index: null, highlightedCommentId: commentId); } final data = state.extra as Map; @@ -47,12 +42,7 @@ class ContentRoutes { final commentId = int.tryParse(commentIdStr ?? ''); if (slug != null) { - return AboutRecipeScreen( - recipeSlug: slug, - navigateToComments: commentId != null, - index: null, - highlightedCommentId: commentId, - ); + return AboutRecipeScreen(recipeSlug: slug, navigateToComments: commentId != null, index: null, highlightedCommentId: commentId); } final data = state.extra as Map; @@ -69,26 +59,28 @@ class ContentRoutes { name: 'aboutDietScreen', builder: (context, state) { final slug = state.uri.queryParameters['slug']; + final idStr = state.uri.queryParameters['id']; + final id = int.tryParse(idStr ?? ''); final commentIdStr = state.uri.queryParameters['commentId']; final commentId = int.tryParse(commentIdStr ?? ''); if (slug != null) { + return AboutDietScreen(id: null, slug: slug, index: null, navigateToComments: commentId != null, highlightedCommentId: commentId); + } + if (id != null && id > 0) { + return AboutDietScreen(id: id, slug: null, index: null, navigateToComments: commentId != null, highlightedCommentId: commentId); + } + final data = state.extra as Map?; + if (data != null) { return AboutDietScreen( - id: null, - slug: slug, - index: null, - navigateToComments: commentId != null, - highlightedCommentId: commentId, + id: data['id'] as int?, + slug: data['slug'] as String?, + index: data['index'] as int?, + navigateToComments: data['navigateToComments'] as bool? ?? (data['commentId'] != null), + highlightedCommentId: data['commentId'] as int?, ); } - final data = state.extra as Map; - return AboutDietScreen( - id: data['id'] as int?, - slug: data['slug'] as String?, - index: data['index'] as int?, - navigateToComments: data['navigateToComments'] as bool? ?? (data['commentId'] != null), - highlightedCommentId: data['commentId'] as int?, - ); + return const AboutDietScreen(id: null, slug: null, index: null, navigateToComments: false, highlightedCommentId: null); }, ), GoRoute( @@ -106,10 +98,7 @@ class ContentRoutes { final parsedId = int.tryParse(id ?? ''); if (parsedId != null) { - return BlogPostScreen( - id: parsedId, - highlightedCommentId: commentId, - ); + return BlogPostScreen(id: parsedId, highlightedCommentId: commentId); } if (state.extra is int) { @@ -117,10 +106,7 @@ class ContentRoutes { } final data = state.extra as Map; - return BlogPostScreen( - id: data['id'] as int, - highlightedCommentId: data['commentId'] as int?, - ); + return BlogPostScreen(id: data['id'] as int, highlightedCommentId: data['commentId'] as int?); }, ), ]; diff --git a/lib/core/routing/main_navigation_routes.dart b/lib/core/routing/main_navigation_routes.dart index f7756fbd..485ff24d 100644 --- a/lib/core/routing/main_navigation_routes.dart +++ b/lib/core/routing/main_navigation_routes.dart @@ -33,7 +33,10 @@ class MainNavigationRoutes { }); } - return _MainShellWithNavScope(child: child); + return _MainShellWithNavScope( + child: child, + currentLocation: state.matchedLocation, + ); }, routes: [ GoRoute( @@ -102,9 +105,13 @@ class MainNavigationRoutes { /// Wraps the shell with [BottomNavScope] so descendants (e.g. notifications) /// can switch tabs via [BottomNavController.switchToRoute] instead of pushing. class _MainShellWithNavScope extends StatefulWidget { - const _MainShellWithNavScope({required this.child}); + const _MainShellWithNavScope({ + required this.child, + required this.currentLocation, + }); final Widget child; + final String currentLocation; @override State<_MainShellWithNavScope> createState() => _MainShellWithNavScopeState(); @@ -117,7 +124,10 @@ class _MainShellWithNavScopeState extends State<_MainShellWithNavScope> { Widget build(BuildContext context) { return BottomNavScope( controller: _navController, - child: BottomNavigationBarWidget(child: widget.child), + child: BottomNavigationBarWidget( + child: widget.child, + currentLocation: widget.currentLocation, + ), ); } } diff --git a/lib/core/websockets/ws_manager.dart b/lib/core/websockets/ws_manager.dart index 3a711177..cf84354c 100644 --- a/lib/core/websockets/ws_manager.dart +++ b/lib/core/websockets/ws_manager.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/core/websockets/ws_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; +import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; class WsManager { final WsService wsService; @@ -111,6 +112,7 @@ class WsManager { } }); _subscribe(); + await _connectCubitsToStreams(); } catch (e, st) { await Sentry.captureException(e, stackTrace: st); log(e.toString(), stackTrace: st); @@ -118,6 +120,20 @@ class WsManager { } } + /// Подключает ChatsCubit и NotificationsCubit к стримам сокета. + /// Вызывается после успешного initialize(), чтобы уведомления и чаты работали + /// и при входе уже залогиненного пользователя (редирект на feed), не только после экрана логина. + Future _connectCubitsToStreams() async { + try { + await sl().connectToWebSocket(); + await sl().getNotifications(); + await sl().connectToWebSocket(); + } catch (e, st) { + log('WsManager._connectCubitsToStreams: $e', stackTrace: st); + await Sentry.captureException(e, stackTrace: st); + } + } + Future _initPrivateNotificationsChannel() async { final token = await TokenStorage.getTokenFromStorage(); _privateNotificationChannel = client?.privateChannel( diff --git a/lib/features/auth/presentation/pages/auth_screen.dart b/lib/features/auth/presentation/pages/auth_screen.dart index bf9074d9..d1c1ccfd 100644 --- a/lib/features/auth/presentation/pages/auth_screen.dart +++ b/lib/features/auth/presentation/pages/auth_screen.dart @@ -21,7 +21,6 @@ import 'package:eatvio_app/core/di/cubit_registrations.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; -import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; @@ -80,9 +79,6 @@ class _AuthScreenState extends State { try { await sl().getMyProfile(); await sl().getChatRoomsWithLoading(); - await sl().connectToWebSocket(); - await sl().getNotifications(); - await sl().connectToWebSocket(); await sl().registerPushToken(); showSuccessToast('Успешный вход'); } catch (e) { @@ -213,9 +209,6 @@ class _AuthScreenState extends State { await sl().initialize(); await sl().getMyProfile(); await sl().getChatRoomsWithLoading(); - await sl().connectToWebSocket(); - await sl().getNotifications(); - await sl().connectToWebSocket(); await sl().registerPushToken(); showSuccessToast('Успешный вход'); } catch (e) { diff --git a/lib/features/auth/presentation/pages/register_via_social_screen.dart b/lib/features/auth/presentation/pages/register_via_social_screen.dart index 1b27237f..1ea307f0 100644 --- a/lib/features/auth/presentation/pages/register_via_social_screen.dart +++ b/lib/features/auth/presentation/pages/register_via_social_screen.dart @@ -13,7 +13,6 @@ import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; -import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/utils/validation_utils.dart'; @@ -74,9 +73,6 @@ class _RegisterViaSocialScreenState extends State { try { await sl().getMyProfile(); await sl().getChatRoomsWithLoading(); - await sl().connectToWebSocket(); - await sl().getNotifications(); - await sl().connectToWebSocket(); await sl().registerPushToken(); showSuccessToast('Успешная регистрация'); } catch (e) { diff --git a/lib/features/auth/presentation/pages/scan_qr_code_screen.dart b/lib/features/auth/presentation/pages/scan_qr_code_screen.dart index 723f95e9..4b90fe70 100644 --- a/lib/features/auth/presentation/pages/scan_qr_code_screen.dart +++ b/lib/features/auth/presentation/pages/scan_qr_code_screen.dart @@ -10,7 +10,6 @@ import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; -import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart'; @@ -76,9 +75,6 @@ class _ScanQRCodeScreenState extends State { try { await sl().getMyProfile(); await sl().getChatRoomsWithLoading(); - await sl().connectToWebSocket(); - await sl().getNotifications(); - await sl().connectToWebSocket(); await sl().registerPushToken(); } catch (e) { rethrow; diff --git a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart index cd534644..207b348a 100644 --- a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart +++ b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart @@ -19,9 +19,15 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; class BottomNavigationBarWidget extends StatefulWidget { - final Widget child; + const BottomNavigationBarWidget({ + super.key, + required this.child, + required this.currentLocation, + }); - const BottomNavigationBarWidget({super.key, required this.child}); + final Widget child; + /// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши). + final String currentLocation; @override State createState() => _BottomNavigationBarWidgetState(); @@ -146,6 +152,16 @@ class _BottomNavigationBarWidgetState extends State { controller.register(_switchToRoute); } + // Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши). + final pathWithoutQuery = widget.currentLocation.split('?').first; + final indexFromRoute = pages!.indexOf(pathWithoutQuery); + final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex; + if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute); + }); + } + return Scaffold( body: widget.child, bottomNavigationBar: BottomNavigationBar( @@ -154,7 +170,7 @@ class _BottomNavigationBarWidgetState extends State { selectedFontSize: 10, unselectedFontSize: 10, selectedItemColor: appTheme.blueA400, - currentIndex: currentBottomNavBarIndex, + currentIndex: effectiveIndex, items: [ BottomNavigationBarItem( label: '', @@ -229,7 +245,7 @@ class _BottomNavigationBarWidgetState extends State { ImageConstant.icnChat, width: 20, height: 20, - colorFilter: pages![currentBottomNavBarIndex] == '/chat-select-screen' + colorFilter: pages![effectiveIndex] == '/chat-select-screen' ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) : const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ), @@ -251,7 +267,7 @@ class _BottomNavigationBarWidgetState extends State { padding: const EdgeInsets.only(bottom: 2), child: SvgPicture.asset( ImageConstant.icnBell, - colorFilter: pages![currentBottomNavBarIndex] == '/notifications-screen' + colorFilter: pages![effectiveIndex] == '/notifications-screen' ? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) : const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, diff --git a/lib/features/notifications/models/notification_model.dart b/lib/features/notifications/models/notification_model.dart index 5715fb90..b178eb91 100644 --- a/lib/features/notifications/models/notification_model.dart +++ b/lib/features/notifications/models/notification_model.dart @@ -166,13 +166,7 @@ class DiaryReminderNotification implements BaseNotificationModel { @override final DateTime createdAt; - DiaryReminderNotification({ - required this.type, - required this.isRead, - this.diaryId, - this.message, - required this.createdAt, - }); + DiaryReminderNotification({required this.type, required this.isRead, this.diaryId, this.message, required this.createdAt}); factory DiaryReminderNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return DiaryReminderNotification( @@ -581,6 +575,22 @@ class ComplaintToModerationForUser implements BaseNotificationModel { } } +enum CommentedEntityType { + diet, + blogPost, + unknown; + + factory CommentedEntityType.fromJson(String type) { + return switch (type) { + 'diet' => CommentedEntityType.diet, + 'Modules\\UserGeneratedPublicContent\\Models\\Diet' => CommentedEntityType.diet, + 'blog-post' => CommentedEntityType.blogPost, + 'Modules\\UserGeneratedPublicContent\\Models\\BlogPost' => CommentedEntityType.blogPost, + _ => CommentedEntityType.unknown, + }; + } +} + class CommentReplied implements BaseNotificationModel { @override final NotificationType type; @@ -588,7 +598,7 @@ class CommentReplied implements BaseNotificationModel { final bool isRead; final String entityName; final int commentedEntityId; - final String commentedEntityType; + final CommentedEntityType commentedEntityType; final String author; final int authorId; final int parentCommentId; @@ -614,7 +624,7 @@ class CommentReplied implements BaseNotificationModel { type: type, entityName: json['entity_name'], commentedEntityId: json['commented_entity_id'], - commentedEntityType: json['commented_entity_type'], + commentedEntityType: CommentedEntityType.fromJson(json['commented_entity_type']), author: json['author'], authorId: json['author_id'], parentCommentId: json['parrent_comment_id'], @@ -673,7 +683,7 @@ class EntityCommented implements BaseNotificationModel { final bool isRead; final String entityName; final int entityId; - final String entityType; + final CommentedEntityType entityType; final String entityTranslatedType; final String entityUrl; final String authorName; @@ -703,7 +713,7 @@ class EntityCommented implements BaseNotificationModel { type: type, entityName: json['entity_name'], entityId: json['entity_id'], - entityType: json['entity_type'], + entityType: CommentedEntityType.fromJson(json['entity_type']), entityTranslatedType: json['entity_traslated_type'], entityUrl: json['entity_url'], authorName: json['author_name'], @@ -1012,11 +1022,7 @@ class UnknownNotification implements BaseNotificationModel { UnknownNotification({required this.type, this.isRead = true, required this.createdAt}); factory UnknownNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { - return UnknownNotification( - type: type, - isRead: isRead ?? false, - createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()), - ); + return UnknownNotification(type: type, isRead: isRead ?? false, createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String())); } } @@ -1889,12 +1895,7 @@ class ClientCloseComplaintSuccessNotification implements BaseNotificationModel { @override final DateTime createdAt; - ClientCloseComplaintSuccessNotification({ - required this.type, - required this.planName, - required this.isRead, - required this.createdAt, - }); + ClientCloseComplaintSuccessNotification({required this.type, required this.planName, required this.isRead, required this.createdAt}); factory ClientCloseComplaintSuccessNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ClientCloseComplaintSuccessNotification( @@ -2001,13 +2002,7 @@ class ConsultantRejectSuccessNotification implements BaseNotificationModel { @override final DateTime createdAt; - ConsultantRejectSuccessNotification({ - required this.type, - required this.planName, - required this.clientName, - required this.isRead, - required this.createdAt, - }); + ConsultantRejectSuccessNotification({required this.type, required this.planName, required this.clientName, required this.isRead, required this.createdAt}); factory ConsultantRejectSuccessNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ConsultantRejectSuccessNotification( @@ -2115,13 +2110,7 @@ class DiaryReminderForUsersNotification implements BaseNotificationModel { @override final DateTime createdAt; - DiaryReminderForUsersNotification({ - required this.type, - required this.isRead, - this.diaryId, - this.message, - required this.createdAt, - }); + DiaryReminderForUsersNotification({required this.type, required this.isRead, this.diaryId, this.message, required this.createdAt}); factory DiaryReminderForUsersNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { final data = json['data'] is Map ? json['data'] as Map : json; @@ -2196,12 +2185,7 @@ class ConsultantEducationUpdatedNotification implements BaseNotificationModel { final DateTime createdAt; final String consultantName; - ConsultantEducationUpdatedNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.consultantName, - }); + ConsultantEducationUpdatedNotification({required this.type, required this.isRead, required this.createdAt, required this.consultantName}); factory ConsultantEducationUpdatedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ConsultantEducationUpdatedNotification( @@ -2258,13 +2242,7 @@ class ContentUnpublishedNotification implements BaseNotificationModel { final String reason; final String entityName; - ContentUnpublishedNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.reason, - required this.entityName, - }); + ContentUnpublishedNotification({required this.type, required this.isRead, required this.createdAt, required this.reason, required this.entityName}); factory ContentUnpublishedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ContentUnpublishedNotification( @@ -2286,12 +2264,7 @@ class FinanceQuestionCreatedNotification implements BaseNotificationModel { final DateTime createdAt; final String questionText; - FinanceQuestionCreatedNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.questionText, - }); + FinanceQuestionCreatedNotification({required this.type, required this.isRead, required this.createdAt, required this.questionText}); factory FinanceQuestionCreatedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return FinanceQuestionCreatedNotification( @@ -2343,11 +2316,7 @@ class CompleteRegistrationNotification implements BaseNotificationModel { @override final DateTime createdAt; - CompleteRegistrationNotification({ - required this.type, - required this.isRead, - required this.createdAt, - }); + CompleteRegistrationNotification({required this.type, required this.isRead, required this.createdAt}); factory CompleteRegistrationNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return CompleteRegistrationNotification( @@ -2368,13 +2337,7 @@ class ChosenDietExpiredNotification implements BaseNotificationModel { final String dietName; final int dietId; - ChosenDietExpiredNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.dietName, - required this.dietId, - }); + ChosenDietExpiredNotification({required this.type, required this.isRead, required this.createdAt, required this.dietName, required this.dietId}); factory ChosenDietExpiredNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return ChosenDietExpiredNotification( @@ -2396,12 +2359,7 @@ class MotivateUserNotification implements BaseNotificationModel { final DateTime createdAt; final String message; - MotivateUserNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.message, - }); + MotivateUserNotification({required this.type, required this.isRead, required this.createdAt, required this.message}); factory MotivateUserNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return MotivateUserNotification( @@ -2423,13 +2381,7 @@ class CommentComplaintNotification implements BaseNotificationModel { final String commentPreview; final String reason; - CommentComplaintNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.commentPreview, - required this.reason, - }); + CommentComplaintNotification({required this.type, required this.isRead, required this.createdAt, required this.commentPreview, required this.reason}); factory CommentComplaintNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return CommentComplaintNotification( @@ -2451,12 +2403,7 @@ class CommentDeletedNotification implements BaseNotificationModel { final DateTime createdAt; final String commentPreview; - CommentDeletedNotification({ - required this.type, - required this.isRead, - required this.createdAt, - required this.commentPreview, - }); + CommentDeletedNotification({required this.type, required this.isRead, required this.createdAt, required this.commentPreview}); factory CommentDeletedNotification.fromJson(Map json, NotificationType type, {bool? isRead, String? createdAt}) { return CommentDeletedNotification( @@ -2655,7 +2602,7 @@ enum NotificationType { 'stay-on-track-to-customer' => NotificationType.stayOnTrackToCustomer, 'diary-reminder-for-users' => NotificationType.diaryReminderForUsers, 'personal-diet-is-defined-to-customer' => NotificationType.personalDietIsDefinedToCustomer, - + // New additions 'consultant-education-updated' => NotificationType.consultantEducationUpdated, 'complaint-registered' => NotificationType.complaintRegistered, @@ -2671,7 +2618,6 @@ enum NotificationType { 'user-subscribed' => NotificationType.userSubscribed, // 'diary-reminder' => NotificationType.diaryReminder, // Already legacy? Check if it maps to diaryReminderNotification 'meal-reminder' => NotificationType.mealReminder, // Maps to legacy if string matches? No, legacy was App\\... - // Legacy mapping 'App\\Notifications\\Plan\\PlanSubscriptionStartedToAuthor' => NotificationType.planSubscriptionStartedToAuthor, 'App\\Notifications\\Plan\\PlanSubscriptionCanceledToAuthor' => NotificationType.planSubscriptionCanceledToAuthor, diff --git a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart index 54c84507..51771c24 100644 --- a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart +++ b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart @@ -349,23 +349,31 @@ class NotificationsCubit extends Cubit { void _handleNotification(String notificationData) { final json = jsonDecode(notificationData) as Map; - // Улучшенная логика определения типа уведомления + // Улучшенная логика определения типа уведомления (порядок: database_type → type → notification_class_name) String? stringifiedType; - // Сначала проверяем поле 'type' в корне JSON - if (json.containsKey('type') && json['type'] != null) { + // Сначала проверяем поле 'database_type' в корне JSON + if (json.containsKey('database_type') && json['database_type'] != null) { + stringifiedType = json['database_type'].toString(); + log('Found database_type in root: $stringifiedType'); + } + // Затем поле 'type' в корне + else if (json.containsKey('type') && json['type'] != null) { stringifiedType = json['type'].toString(); log('Found type in root: $stringifiedType'); } - // Затем проверяем поле 'notification_class_name' в корне JSON + // Затем поле 'notification_class_name' в корне else if (json.containsKey('notification_class_name') && json['notification_class_name'] != null) { stringifiedType = json['notification_class_name'].toString(); log('Found notification_class_name in root: $stringifiedType'); } - // Если есть поле 'data', проверяем внутри него + // Если есть поле 'data', проверяем внутри него в том же порядке else if (json.containsKey('data') && json['data'] is Map) { final data = json['data'] as Map; - if (data.containsKey('type') && data['type'] != null) { + if (data.containsKey('database_type') && data['database_type'] != null) { + stringifiedType = data['database_type'].toString(); + log('Found database_type in data: $stringifiedType'); + } else if (data.containsKey('type') && data['type'] != null) { stringifiedType = data['type'].toString(); log('Found type in data: $stringifiedType'); } else if (data.containsKey('notification_class_name') && data['notification_class_name'] != null) { diff --git a/lib/features/notifications/presentation/widgets/notification_widget.dart b/lib/features/notifications/presentation/widgets/notification_widget.dart index f33b9cb1..cdde4bbf 100644 --- a/lib/features/notifications/presentation/widgets/notification_widget.dart +++ b/lib/features/notifications/presentation/widgets/notification_widget.dart @@ -147,17 +147,16 @@ void _navigateToBottomNavRoute(BuildContext context, String path, [Object? extra } } -void _navigateToEntity(BuildContext context, String type, int id, int? commentId) { - if (type.contains('BlogPost')) { - context.push(RouteConstants.blogPostScreen, extra: {'id': id, 'highlightedCommentId': commentId}); - } else if (type.contains('Recipe')) { - // TODO: aboutRecipesScreen требует recipe slug, в уведомлении только id — навигация отключена - } else if (type.contains('Plan')) { - // TODO: aboutPlanScreen требует slug плана, в уведомлении только id — навигация отключена - } else if (type.contains('Diet')) { - if (id > 0) { - context.push(RouteConstants.aboutDietScreen, extra: {'id': id, 'highlightedCommentId': commentId}); - } +void _navigateToEntity(BuildContext context, CommentedEntityType type, int id, int? commentId) { + switch (type) { + case CommentedEntityType.blogPost: + context.push(RouteConstants.blogPostScreen, extra: {'id': id, 'highlightedCommentId': commentId}); + case CommentedEntityType.diet: + if (id > 0) { + context.push(RouteConstants.aboutDietScreen, extra: {'id': id, 'highlightedCommentId': commentId}); + } + case CommentedEntityType.unknown: + break; } } @@ -184,7 +183,6 @@ class _PlanSubscriptionCanceledToAuthorWidget extends StatelessWidget { final PlanSubscriptionCanceledToAuthorNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Отмена подписки. Клиент ${notification.customerName} отказался от плана "${notification.planName}"', @@ -201,7 +199,6 @@ class _ChosenPlanStartedToClientWidget extends StatelessWidget { final ChosenPlanStartedToClientNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Ваш план "${notification.planName}" начался! Удачи! 🚀', @@ -218,7 +215,6 @@ class _ChosenPlanTomorrowReminderToClientWidget extends StatelessWidget { final ChosenPlanTomorrowReminderToClientNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Напоминание: Завтра старт плана "${notification.planName}"', @@ -250,7 +246,6 @@ class _ConsultantLeftCommentWidget extends StatelessWidget { final ConsultantLeftCommentNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Новый комментарий от консультанта (за ${notification.date.format('dd.MM.yyyy')})', @@ -283,7 +278,6 @@ class _ChosenPlanFinishedToClientWidget extends StatelessWidget { final ChosenPlanFinishedToClientNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Поздравляем! План «${notification.planName}» успешно завершен! 🏆', @@ -297,7 +291,6 @@ class _PlanSubscriptionCanceledToCustomerWidget extends StatelessWidget { final PlanSubscriptionCanceledToCustomerNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Подписка на план "${notification.planName}" отменена.', @@ -311,7 +304,6 @@ class _ChosenPlanStartedToConsultantWidget extends StatelessWidget { final ChosenPlanStartedToConsultantNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Начало работы: План "${notification.planName}" с клиентом ${notification.customerName}', @@ -325,7 +317,6 @@ class _ChosenPlanTomorrowReminderToConsultantWidget extends StatelessWidget { final ChosenPlanTomorrowReminderToConsultantNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Напоминание: Завтра старт работы с ${notification.customerName} по плану "${notification.planName}"', @@ -339,7 +330,6 @@ class _PlanSubscriptionPaidToAuthorWidget extends StatelessWidget { final PlanSubscriptionPaidToAuthorNotification notification; @override Widget build(BuildContext context) { - return _BaseNotificationWidget( isRead: notification.isRead, notificationsText: 'Оплата получена! Клиент ${notification.customerName} оплатил план "${notification.planName}" 💰', @@ -548,11 +538,6 @@ class _ComplaintToModerationForUserWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Ваша жалоба принята и находится на модерации.', createdAt: notification.createdAt, - onTap: (['Plan', 'Recipe'].contains(notification.entityType) || notification.entityId <= 0 || notification.entityType.isEmpty) - ? null - : () { - _navigateToEntity(context, notification.entityType, notification.entityId, null); - }, ); } } @@ -566,7 +551,7 @@ class _CommentRepliedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Вам ответили в комментариях к: ${notification.entityName}', createdAt: notification.createdAt, - onTap: (['Plan', 'Recipe'].contains(notification.commentedEntityType) || notification.commentedEntityId <= 0 || notification.commentId == null) + onTap: (notification.commentedEntityType == CommentedEntityType.unknown || notification.commentedEntityId <= 0) ? null : () { _navigateToEntity(context, notification.commentedEntityType, notification.commentedEntityId, notification.commentId); @@ -600,9 +585,11 @@ class _EntityCommentedWidget extends StatelessWidget { isRead: notification.isRead, notificationsText: 'Новый комментарий к вашей публикации "${notification.entityTranslatedType}": ${notification.entityName}', createdAt: notification.createdAt, - onTap: () { - _navigateToEntity(context, notification.entityType, notification.entityId, notification.commentId); - }, + onTap: (notification.entityType == CommentedEntityType.unknown || notification.entityId <= 0) + ? null + : () { + _navigateToEntity(context, notification.entityType, notification.entityId, notification.commentId); + }, ); } } diff --git a/lib/main.dart b/lib/main.dart index af6db638..6513e685 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/notifications/push_navigation_handler.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_portal/flutter_portal.dart'; @@ -13,9 +14,20 @@ Future main() async { await AppInitializer.runAppWithSentry(() => runApp(const MyApp())); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + void initState() { + super.initState(); + PushNavigationHandler.initialize(); + } + @override Widget build(BuildContext context) { return MultiBlocProvider( diff --git a/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart b/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart index 721af4b3..070a4c25 100644 --- a/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart +++ b/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart @@ -109,7 +109,10 @@ void main() { ], child: wrap( const Scaffold( - body: BottomNavigationBarWidget(child: Text('Test Content')), + body: BottomNavigationBarWidget( + child: Text('Test Content'), + currentLocation: '/feed-screen', + ), ), ), ), @@ -145,7 +148,10 @@ void main() { ], child: wrap( const Scaffold( - body: BottomNavigationBarWidget(child: Text('Test Content')), + body: BottomNavigationBarWidget( + child: Text('Test Content'), + currentLocation: '/feed-screen', + ), ), ), ), diff --git a/test/features/notifications/presentation/widgets/notification_widget_test.dart b/test/features/notifications/presentation/widgets/notification_widget_test.dart index db0ccb71..d8f56164 100644 --- a/test/features/notifications/presentation/widgets/notification_widget_test.dart +++ b/test/features/notifications/presentation/widgets/notification_widget_test.dart @@ -311,7 +311,7 @@ void main() { type: NotificationType.commentReplied, entityName: 'Test Entity', commentedEntityId: 1, - commentedEntityType: 'test', + commentedEntityType: CommentedEntityType.diet, author: 'Test Author', authorId: 1, parentCommentId: 1, @@ -349,7 +349,7 @@ void main() { type: NotificationType.entityCommented, entityName: 'Test Entity', entityId: 1, - entityType: 'test', + entityType: CommentedEntityType.diet, entityTranslatedType: 'Test Type', entityUrl: 'test_url', authorName: 'Test Author', From 3892bf00aa78d3771caade8900b37aeecf8204a0 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 4 Mar 2026 09:25:11 +0300 Subject: [PATCH 8/9] fix: analizer --- lib/core/routing/main_navigation_routes.dart | 4 ++-- .../widgets/bottom_navigation_bar_widget_test.dart | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/core/routing/main_navigation_routes.dart b/lib/core/routing/main_navigation_routes.dart index 485ff24d..2557ddcf 100644 --- a/lib/core/routing/main_navigation_routes.dart +++ b/lib/core/routing/main_navigation_routes.dart @@ -34,8 +34,8 @@ class MainNavigationRoutes { } return _MainShellWithNavScope( - child: child, currentLocation: state.matchedLocation, + child: child, ); }, routes: [ @@ -125,8 +125,8 @@ class _MainShellWithNavScopeState extends State<_MainShellWithNavScope> { return BottomNavScope( controller: _navController, child: BottomNavigationBarWidget( - child: widget.child, currentLocation: widget.currentLocation, + child: widget.child, ), ); } diff --git a/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart b/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart index 070a4c25..85850c2e 100644 --- a/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart +++ b/test/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget_test.dart @@ -110,8 +110,8 @@ void main() { child: wrap( const Scaffold( body: BottomNavigationBarWidget( - child: Text('Test Content'), currentLocation: '/feed-screen', + child: Text('Test Content'), ), ), ), @@ -149,8 +149,8 @@ void main() { child: wrap( const Scaffold( body: BottomNavigationBarWidget( - child: Text('Test Content'), currentLocation: '/feed-screen', + child: Text('Test Content'), ), ), ), From 07277d6e482e7a3c82300f58ebba237f05397f79 Mon Sep 17 00:00:00 2001 From: mso7nueh Date: Wed, 11 Mar 2026 12:15:33 +0000 Subject: [PATCH 9/9] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20pubspec.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 0e6808b1..b9a6df28 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.17+97 +version: 1.3.18+98 publish_to: 'none' environment: