From 04de95676984c6344b5e1116a1a5eef77f55eaeb Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 4 Aug 2026 17:47:43 +0300 Subject: [PATCH 1/4] feat(post_creation): added multiple image posts creation and validation --- lib/core/widgets/custom_popup.dart | 19 +- .../blog_post_create_cubit.dart | 168 ++++- .../blog_post_create_state.dart | 24 +- .../pages/blog_post_create_screen.dart | 695 +++++++++++++----- .../repositories/blog_post_repository.dart | 149 ++-- .../services/blog_post_service.dart | 18 +- .../validation/blog_post_form_validator.dart | 158 ++++ .../bloc/profile_cubit/profile_cubit.dart | 20 + .../presentation/pages/profile_screen.dart | 7 +- .../widgets/profile_blog_post_widget.dart | 1 + .../advanced_widget_test.dart | 253 +++++-- .../blog_post_create/basic_widget_test.dart | 347 ++++++--- .../blog_post_create_cubit_test.dart | 255 ++++++- .../integration_widget_test.dart | 422 +++++++---- .../pages/blog_post_create_screen_test.dart | 655 +++++++++++++---- .../blog_post_repository_test.dart | 308 ++++---- .../services/blog_post_service_test.dart | 32 +- .../blog_post_form_validator_test.dart | 191 +++++ .../blog_post_create/create_request_full.json | 3 +- .../blog_post_create/update_request.json | 5 +- test/fixtures/doubles/blog_post_doubles.dart | 43 +- 21 files changed, 2791 insertions(+), 982 deletions(-) create mode 100644 lib/features/blog_post_create/validation/blog_post_form_validator.dart create mode 100644 test/features/blog_post_create/validation/blog_post_form_validator_test.dart diff --git a/lib/core/widgets/custom_popup.dart b/lib/core/widgets/custom_popup.dart index dbe0090b..51f6f4ff 100644 --- a/lib/core/widgets/custom_popup.dart +++ b/lib/core/widgets/custom_popup.dart @@ -6,6 +6,7 @@ import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart'; import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart'; +import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; @@ -13,9 +14,11 @@ import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cub import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.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:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart'; @@ -30,6 +33,7 @@ class CustomPopup extends StatelessWidget { final Recipe? recipe; final Diet? diet; final String userSlug; + final VoidCallback? onPostsChanged; const CustomPopup({ super.key, @@ -41,7 +45,8 @@ class CustomPopup extends StatelessWidget { this.feedBlogPostModel, this.recipe, this.diet, - required this.userSlug + required this.userSlug, + this.onPostsChanged, }); @override @@ -103,7 +108,8 @@ class CustomPopup extends StatelessWidget { if (complainedContentType == ComplainedContent.posts) { addItem('assets/icons/edit_paint.svg', 'Редактировать', () async { if (complainedContentType == ComplainedContent.posts) { - GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel); + await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel); + onPostsChanged?.call(); } }); } @@ -114,7 +120,14 @@ class CustomPopup extends StatelessWidget { showErrorDialog(context, title: S.current.could_not_delete_blog_post); return; } - showSuccessToast('Пост удален, обновите ленту'); + onPostsChanged?.call(); + ProfileCubit.myProfileCubit?.reloadBlogPosts(); + if (context.mounted) { + try { + context.read().loadFeedBlogPostList(); + } catch (_) {} + } + showSuccessToast('Пост удален'); } else if (complainedContentType == ComplainedContent.recipe) { final result = await sl().deleteRecipe(slug!); if (!result && context.mounted) { diff --git a/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart b/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart index 5d04bdaf..33a584ac 100644 --- a/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart +++ b/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart @@ -3,38 +3,184 @@ import 'dart:developer'; import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart'; import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart'; +import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; class BlogPostCreateCubit extends Cubit { final BlogPostRepository blogPostRepository; - BlogPostCreateCubit({required this.blogPostRepository}) : super(const BlogPostCreateState()); + BlogPostCreateCubit({required this.blogPostRepository}) + : super(const BlogPostCreateState()); + + void clearTitleError() { + if (state.titleError != null || state.formError != null) { + emit(state.copyWith(titleError: null, formError: null)); + } + } + + void clearDescriptionError() { + if (state.descriptionError != null || state.formError != null) { + emit(state.copyWith(descriptionError: null, formError: null)); + } + } + + void clearVideoUrlError() { + if (state.videoUrlError != null || state.formError != null) { + emit(state.copyWith(videoUrlError: null, formError: null)); + } + } + + void clearImagesError() { + if (state.imagesError != null || state.formError != null) { + emit(state.copyWith(imagesError: null, formError: null)); + } + } + + void resetFormErrors() { + if (!state.hasValidationErrors && + !state.isSuccess && + state.errorMessage == null && + !state.isLoading) { + return; + } + emit(const BlogPostCreateState()); + } + + /// Validates form fields and emits error messages on [BlogPostCreateState]. + /// Returns `true` when the form is valid. + bool validateForm({ + required String title, + required String description, + required String videoUrl, + required int imageCount, + Iterable imagePaths = const [], + }) { + final titleError = BlogPostFormValidator.title(title); + final descriptionError = BlogPostFormValidator.description(description); + final videoUrlError = BlogPostFormValidator.videoUrl(videoUrl); + final imagesError = + BlogPostFormValidator.galleryCount(imageCount) ?? + BlogPostFormValidator.imagePaths(imagePaths); + final formError = BlogPostFormValidator.contentRequiredMessage( + title: title, + description: description, + videoUrl: videoUrl, + imageCount: imageCount, + ); + + final hasFieldErrors = + titleError != null || + descriptionError != null || + videoUrlError != null || + imagesError != null; + final resolvedFormError = hasFieldErrors ? null : formError; + + emit( + state.copyWith( + titleError: titleError, + descriptionError: descriptionError, + videoUrlError: videoUrlError, + imagesError: imagesError, + // Form-level message only when individual fields are fine but content is empty. + formError: resolvedFormError, + isSuccess: false, + errorMessage: null, + ), + ); + + return titleError == null && + descriptionError == null && + videoUrlError == null && + imagesError == null && + resolvedFormError == null; + } + + Future createBlogPost({ + String? title, + String? videoUrl, + String? description, + List images = const [], + }) async { + final trimmedTitle = title?.trim() ?? ''; + final trimmedDescription = description?.trim() ?? ''; + final trimmedVideoUrl = videoUrl?.trim() ?? ''; + + final isValid = validateForm( + title: trimmedTitle, + description: trimmedDescription, + videoUrl: trimmedVideoUrl, + imageCount: images.length, + imagePaths: images.map((image) => image.path), + ); + if (!isValid) { + return false; + } - Future createBlogPost({String? title, String? videoUrl, String? description, XFile? image}) async { try { - emit(const BlogPostCreateState.loading()); - await blogPostRepository.createBlogPost(title, videoUrl, description, image); - emit(const BlogPostCreateState.loaded()); + emit( + state.copyWith(isLoading: true, isSuccess: false, errorMessage: null), + ); + await blogPostRepository.createBlogPost( + title: trimmedTitle.isEmpty ? null : trimmedTitle, + videoUrl: trimmedVideoUrl.isEmpty ? null : trimmedVideoUrl, + description: trimmedDescription.isEmpty ? null : trimmedDescription, + images: images, + ); + emit(const BlogPostCreateState(isSuccess: true)); return true; } catch (error, stackTrace) { log('BlogPostCreateCubit: $error', stackTrace: stackTrace); await reportFrom(error, stackTrace, this); - emit(BlogPostCreateState.error(errorMessage: error.toString())); + emit(BlogPostCreateState(errorMessage: error.toString())); return false; } } - Future updateBlogPost({required int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl}) async { + Future updateBlogPost({ + required int id, + String? title, + String? videoUrl, + String? description, + List existingMediaIds = const [], + List newImages = const [], + List mediaIdsToDelete = const [], + }) async { + final trimmedTitle = title?.trim() ?? ''; + final trimmedDescription = description?.trim() ?? ''; + final trimmedVideoUrl = videoUrl?.trim() ?? ''; + final imageCount = existingMediaIds.length + newImages.length; + + final isValid = validateForm( + title: trimmedTitle, + description: trimmedDescription, + videoUrl: trimmedVideoUrl, + imageCount: imageCount, + imagePaths: newImages.map((image) => image.path), + ); + if (!isValid) { + return false; + } + try { - emit(const BlogPostCreateState.loading()); - await blogPostRepository.updateBlogPost(id, title, videoUrl, description, image, oldImageUrl); - emit(const BlogPostCreateState.loaded()); + emit( + state.copyWith(isLoading: true, isSuccess: false, errorMessage: null), + ); + await blogPostRepository.updateBlogPost( + id: id, + title: trimmedTitle, + videoUrl: trimmedVideoUrl, + description: trimmedDescription, + existingMediaIds: existingMediaIds, + newImages: newImages, + mediaIdsToDelete: mediaIdsToDelete, + ); + emit(const BlogPostCreateState(isSuccess: true)); return true; } catch (error, stackTrace) { log('BlogPostCreateCubit: $error', stackTrace: stackTrace); await reportFrom(error, stackTrace, this); - emit(BlogPostCreateState.error(errorMessage: error.toString())); + emit(BlogPostCreateState(errorMessage: error.toString())); return false; } } diff --git a/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart b/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart index 05919499..d4445fbf 100644 --- a/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart +++ b/lib/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart @@ -4,13 +4,23 @@ part 'blog_post_create_state.freezed.dart'; @freezed abstract class BlogPostCreateState with _$BlogPostCreateState { - const factory BlogPostCreateState() = _BlogPostCreateState; + const BlogPostCreateState._(); - const factory BlogPostCreateState.loading() = BlogPostCreateStateLoading; + const factory BlogPostCreateState({ + @Default(false) bool isLoading, + @Default(false) bool isSuccess, + String? errorMessage, + String? titleError, + String? descriptionError, + String? videoUrlError, + String? imagesError, + String? formError, + }) = _BlogPostCreateState; - const factory BlogPostCreateState.loaded() = BlogPostCreateStateLoaded; - - const factory BlogPostCreateState.error({ - required String errorMessage, - }) = BlogPostCreateStateError; + bool get hasValidationErrors => + titleError != null || + descriptionError != null || + videoUrlError != null || + imagesError != null || + formError != null; } 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 6da09b9e..b3c9b42c 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 @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:io'; + import 'package:eatvio_app/app_keys.dart'; -import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/permission_handler/permission_handler.dart'; import 'package:eatvio_app/core/theme/app_decoration.dart'; @@ -12,8 +12,13 @@ import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; +import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart'; +import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -36,20 +41,31 @@ class _BlogPostCreateScreenState extends State { final blogPostCubit = sl(); bool shrinkImage = false; late StreamSubscription keyboardSubscription; - XFile? image; + final List savedImages = []; + final List pendingImages = []; + final List mediaIdsToDelete = []; final titleController = TextEditingController(); final descriptionController = TextEditingController(); final videoUrlController = TextEditingController(); - bool isLoading = false; bool enableMdPanel = false; bool isPreviewMode = false; final FocusNode descriptionFocusNode = FocusNode(); + bool get _isEditMode => widget.feedBlogPostModel != null; + + int get _galleryCount => savedImages.length + pendingImages.length; + + int get _remainingSlots => + BlogPostRepository.maxGalleryImages - _galleryCount; + @override void initState() { super.initState(); + blogPostCubit.resetFormErrors(); var keyboardVisibilityController = KeyboardVisibilityController(); - keyboardSubscription = keyboardVisibilityController.onChange.listen((bool visible) { + keyboardSubscription = keyboardVisibilityController.onChange.listen(( + bool visible, + ) { setState(() { shrinkImage = visible; enableMdPanel = visible; @@ -59,6 +75,9 @@ class _BlogPostCreateScreenState extends State { titleController.text = widget.feedBlogPostModel!.title; descriptionController.text = widget.feedBlogPostModel!.description; videoUrlController.text = widget.feedBlogPostModel!.videoUrl; + savedImages.addAll( + widget.feedBlogPostModel!.images.where((image) => image.id != null), + ); } } @@ -66,6 +85,9 @@ class _BlogPostCreateScreenState extends State { void dispose() { keyboardSubscription.cancel(); descriptionFocusNode.dispose(); + titleController.dispose(); + descriptionController.dispose(); + videoUrlController.dispose(); super.dispose(); } @@ -78,9 +100,16 @@ class _BlogPostCreateScreenState extends State { return selectedText.startsWith(prefix) && selectedText.endsWith(suffix); } else { final cursorPosition = selection.baseOffset; - if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) { - final beforeCursor = text.substring(cursorPosition - prefix.length, cursorPosition); - final afterCursor = text.substring(cursorPosition, cursorPosition + suffix.length); + if (cursorPosition >= prefix.length && + text.length >= cursorPosition + suffix.length) { + final beforeCursor = text.substring( + cursorPosition - prefix.length, + cursorPosition, + ); + final afterCursor = text.substring( + cursorPosition, + cursorPosition + suffix.length, + ); return beforeCursor == prefix && afterCursor == suffix; } } @@ -93,23 +122,36 @@ class _BlogPostCreateScreenState extends State { if (selection.isValid && !selection.isCollapsed) { final selectedText = text.substring(selection.start, selection.end); - final unformattedText = selectedText.substring(prefix.length, selectedText.length - suffix.length); + final unformattedText = selectedText.substring( + prefix.length, + selectedText.length - suffix.length, + ); - final newText = text.replaceRange(selection.start, selection.end, unformattedText); + final newText = text.replaceRange( + selection.start, + selection.end, + unformattedText, + ); descriptionController.text = newText; - final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + unformattedText.length); + final newSelection = TextSelection( + baseOffset: selection.start, + extentOffset: selection.start + unformattedText.length, + ); descriptionController.selection = newSelection; } else { final cursorPosition = selection.baseOffset; - if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) { + if (cursorPosition >= prefix.length && + text.length >= cursorPosition + suffix.length) { final beforeCursor = text.substring(0, cursorPosition - prefix.length); final afterCursor = text.substring(cursorPosition + suffix.length); final newText = beforeCursor + afterCursor; descriptionController.text = newText; final newCursorPosition = cursorPosition - prefix.length; - descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition); + descriptionController.selection = TextSelection.collapsed( + offset: newCursorPosition, + ); } } } @@ -122,18 +164,28 @@ class _BlogPostCreateScreenState extends State { final selectedText = text.substring(selection.start, selection.end); final formattedText = '$prefix$selectedText$suffix'; - final newText = text.replaceRange(selection.start, selection.end, formattedText); + final newText = text.replaceRange( + selection.start, + selection.end, + formattedText, + ); descriptionController.text = newText; - final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + formattedText.length); + final newSelection = TextSelection( + baseOffset: selection.start, + extentOffset: selection.start + formattedText.length, + ); descriptionController.selection = newSelection; } else { final cursorPosition = selection.baseOffset; - final newText = '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}'; + final newText = + '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}'; descriptionController.text = newText; final newCursorPosition = cursorPosition + prefix.length; - descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition); + descriptionController.selection = TextSelection.collapsed( + offset: newCursorPosition, + ); } } @@ -188,17 +240,31 @@ class _BlogPostCreateScreenState extends State { if (linkRegex.hasMatch(selectedText)) { final match = linkRegex.firstMatch(selectedText); final linkText = match?.group(1) ?? selectedText; - final newText = text.replaceRange(selection.start, selection.end, linkText); + final newText = text.replaceRange( + selection.start, + selection.end, + linkText, + ); descriptionController.text = newText; - final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + linkText.length); + final newSelection = TextSelection( + baseOffset: selection.start, + extentOffset: selection.start + linkText.length, + ); descriptionController.selection = newSelection; } else { final formattedText = '[$selectedText](url)'; - final newText = text.replaceRange(selection.start, selection.end, formattedText); + final newText = text.replaceRange( + selection.start, + selection.end, + formattedText, + ); descriptionController.text = newText; - final newSelection = TextSelection(baseOffset: selection.start + selectedText.length + 2, extentOffset: selection.start + selectedText.length + 5); + final newSelection = TextSelection( + baseOffset: selection.start + selectedText.length + 2, + extentOffset: selection.start + selectedText.length + 5, + ); descriptionController.selection = newSelection; } } else { @@ -209,11 +275,14 @@ class _BlogPostCreateScreenState extends State { void _applyHorizontalRule() { final cursorPosition = descriptionController.selection.baseOffset; final text = descriptionController.text; - final newText = '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}'; + final newText = + '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}'; descriptionController.text = newText; final newCursorPosition = cursorPosition + 5; - descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition); + descriptionController.selection = TextSelection.collapsed( + offset: newCursorPosition, + ); } void _togglePreview() { @@ -240,7 +309,9 @@ class _BlogPostCreateScreenState extends State { borderRadius: BorderRadius.circular(20), border: Border.all(color: const Color(0xFFE0E0E0)), ), - child: SingleChildScrollView(child: _parseMarkdown(descriptionController.text)), + child: SingleChildScrollView( + child: _parseMarkdown(descriptionController.text), + ), ); } @@ -248,7 +319,11 @@ class _BlogPostCreateScreenState extends State { if (text.isEmpty) { return const Text( 'Описание', - style: TextStyle(fontSize: 16, color: Color(0xFF323F4B), fontWeight: FontWeight.w500), + style: TextStyle( + fontSize: 16, + color: Color(0xFF323F4B), + fontWeight: FontWeight.w500, + ), ); } @@ -267,21 +342,33 @@ class _BlogPostCreateScreenState extends State { widgets.add( Text( line.substring(2), - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), ); } else if (line.startsWith('## ')) { widgets.add( Text( line.substring(3), - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), ); } else if (line.startsWith('### ')) { widgets.add( Text( line.substring(4), - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), ); } else if (line.startsWith('- ') || line.startsWith('* ')) { @@ -307,7 +394,11 @@ class _BlogPostCreateScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('$number. ', style: const TextStyle(fontSize: 16)), - Expanded(child: _parseInlineMarkdown(line.substring(number.length + 2))), + Expanded( + child: _parseInlineMarkdown( + line.substring(number.length + 2), + ), + ), ], ), ), @@ -323,7 +414,10 @@ class _BlogPostCreateScreenState extends State { } } - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: widgets, + ); } Widget _parseInlineMarkdown(String text) { @@ -347,14 +441,22 @@ class _BlogPostCreateScreenState extends State { spans.add( TextSpan( text: matchedText.substring(2, matchedText.length - 2), - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black, + ), ), ); } else if (matchedText.startsWith('*') && matchedText.endsWith('*')) { spans.add( TextSpan( text: matchedText.substring(1, matchedText.length - 1), - style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.black), + style: const TextStyle( + fontSize: 16, + fontStyle: FontStyle.italic, + color: Colors.black, + ), ), ); } else if (matchedText.startsWith('[') && matchedText.contains('](')) { @@ -364,7 +466,11 @@ class _BlogPostCreateScreenState extends State { spans.add( TextSpan( text: linkText, - style: const TextStyle(fontSize: 16, color: Colors.blue, decoration: TextDecoration.underline), + style: const TextStyle( + fontSize: 16, + color: Colors.blue, + decoration: TextDecoration.underline, + ), ), ); } @@ -390,9 +496,11 @@ class _BlogPostCreateScreenState extends State { return BlocProvider( create: (BuildContext context) => blogPostCubit, child: BlocListener( + bloc: blogPostCubit, listener: (BuildContext context, BlogPostCreateState state) { - if (state is BlogPostCreateStateLoaded) { + if (state.isSuccess) { context.read().loadFeedBlogPostList(); + ProfileCubit.myProfileCubit?.reloadBlogPosts(); } }, child: _buildBlogPostCreate(context), @@ -401,6 +509,10 @@ class _BlogPostCreateScreenState extends State { } void _showImagePicker() { + if (_remainingSlots <= 0) { + return; + } + showCupertinoModalPopup( context: context, builder: (context) { @@ -415,14 +527,27 @@ class _BlogPostCreateScreenState extends State { description: s.allowAccessToCameraDescription, onGranted: () async { final picker = ImagePicker(); - image = await picker.pickImage(source: ImageSource.camera); - setState(() {}); + final image = await picker.pickImage( + source: ImageSource.camera, + ); + if (image != null && _remainingSlots > 0) { + setState(() { + pendingImages.add(image); + }); + blogPostCubit.clearImagesError(); + } AppRouter.navigatorKey.currentContext?.pop(); }, context: context, ); }, - child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]), + child: const Row( + children: [ + Icon(Icons.photo_camera), + SizedBox(width: 8), + Text('Фото'), + ], + ), ), CupertinoActionSheetAction( onPressed: () async { @@ -432,30 +557,88 @@ class _BlogPostCreateScreenState extends State { description: s.allowAcessToGaleryDescription, onGranted: () async { final picker = ImagePicker(); - image = await picker.pickImage(source: ImageSource.gallery); - setState(() {}); + final images = await picker.pickMultiImage( + limit: _remainingSlots, + ); + if (images.isNotEmpty) { + setState(() { + pendingImages.addAll(images.take(_remainingSlots)); + }); + blogPostCubit.clearImagesError(); + } if (!context.mounted) return; AppRouter.navigatorKey.currentContext?.pop(); }, context: context, ); }, - child: const Row(children: [Icon(Icons.photo), SizedBox(width: 8), Text('Галерея')]), + child: const Row( + children: [ + Icon(Icons.photo), + SizedBox(width: 8), + Text('Галерея'), + ], + ), ), ], - cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')), + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.of(context).pop(), + isDestructiveAction: true, + child: const Text('Отмена'), + ), ); }, ); } + void _removeSavedImage(int index) { + final image = savedImages[index]; + final mediaId = image.id; + setState(() { + savedImages.removeAt(index); + if (mediaId != null) { + mediaIdsToDelete.add(mediaId); + } + }); + } + + void _removePendingImage(int index) { + setState(() { + pendingImages.removeAt(index); + }); + } + + void _onReorderGallery(int oldIndex, int newIndex) { + setState(() { + if (newIndex > oldIndex) { + newIndex -= 1; + } + + if (oldIndex < savedImages.length && newIndex < savedImages.length) { + final item = savedImages.removeAt(oldIndex); + savedImages.insert(newIndex, item); + return; + } + + final pendingOld = oldIndex - savedImages.length; + final pendingNew = newIndex - savedImages.length; + if (pendingOld >= 0 && pendingNew >= 0) { + final item = pendingImages.removeAt(pendingOld); + pendingImages.insert(pendingNew, item); + } + }); + } + Widget _mdButton(IconData icon, Function() onTap) { return Expanded( child: GestureDetector( onTap: onTap, child: Container( height: 36, - decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)), + decoration: BoxDecoration( + color: const Color(0xFFF1F2F6), + borderRadius: BorderRadius.circular(10), + ), child: Icon(icon), ), ), @@ -468,11 +651,18 @@ class _BlogPostCreateScreenState extends State { onTap: onTap, child: Container( height: 36, - decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)), + decoration: BoxDecoration( + color: const Color(0xFFF1F2F6), + borderRadius: BorderRadius.circular(10), + ), child: Center( child: Text( text, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.black87), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: Colors.black87, + ), ), ), ), @@ -496,7 +686,12 @@ class _BlogPostCreateScreenState extends State { color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [ - BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)), + BoxShadow( + color: const Color(0x00000000).withValues(alpha: 0.2), + spreadRadius: 0, + blurRadius: 32, + offset: const Offset(0, 0), + ), ], ), child: Row( @@ -508,9 +703,15 @@ class _BlogPostCreateScreenState extends State { const SizedBox(width: 8), _mdButton(Icons.format_italic, _applyItalic), const SizedBox(width: 8), - _mdButton(Icons.format_list_numbered, _applyNumberedList), + _mdButton( + Icons.format_list_numbered, + _applyNumberedList, + ), const SizedBox(width: 8), - _mdButton(Icons.format_list_bulleted, _applyBulletedList), + _mdButton( + Icons.format_list_bulleted, + _applyBulletedList, + ), const SizedBox(width: 8), _mdButton(Icons.link, _applyLink), const SizedBox(width: 8), @@ -528,157 +729,297 @@ class _BlogPostCreateScreenState extends State { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))], + boxShadow: [ + BoxShadow( + color: const Color(0x00000000).withValues(alpha: 0.2), + spreadRadius: 0, + blurRadius: 32, + offset: const Offset(0, 0), + ), + ], + ), + child: Row( + children: [ + _mdButton( + isPreviewMode ? Icons.edit : Icons.visibility, + _togglePreview, + ), + ], ), - child: Row(children: [_mdButton(isPreviewMode ? Icons.edit : Icons.visibility, _togglePreview)]), ), ], ) : null, appBar: AppBar(), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), + body: BlocBuilder( + bloc: blogPostCubit, + builder: (context, state) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + const Text('Заголовок'), + const SizedBox(height: 8), + CustomTextFormField( + key: AppKeys.blogPostCreateScreenTitleTextField, + controller: titleController, + hasError: state.titleError != null, + onChanged: (_) => blogPostCubit.clearTitleError(), + ), + if (state.titleError != null) ...[ + const SizedBox(height: 4), + ValidationErrorWidget(error: state.titleError!), + ], + const SizedBox(height: 16), + const Text('Описание'), + const SizedBox(height: 8), + isPreviewMode + ? _buildMarkdownPreview() + : CustomTextFormField( + key: AppKeys.blogPostCreateScreenContentTextField, + controller: descriptionController, + focusNode: descriptionFocusNode, + maxLines: 6, + minLines: 4, + hasError: state.descriptionError != null, + onChanged: (_) => blogPostCubit.clearDescriptionError(), + ), + if (state.descriptionError != null) ...[ + const SizedBox(height: 4), + ValidationErrorWidget(error: state.descriptionError!), + ], + const SizedBox(height: 16), + Text( + 'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})', + ), + const SizedBox(height: 8), + _buildGallery(), + if (state.imagesError != null) ...[ + const SizedBox(height: 4), + ValidationErrorWidget(error: state.imagesError!), + ], + const SizedBox(height: 16), + const Text('Ссылка на видео'), + const SizedBox(height: 4), + Text( + 'Rutube, VK или OK', + style: TextStyle(fontSize: 13, color: appTheme.blueGray300), + ), + const SizedBox(height: 8), + CustomTextFormField( + key: AppKeys.blogPostCreateScreenVideoLinkTextField, + controller: videoUrlController, + hasError: state.videoUrlError != null, + keyboardType: TextInputType.url, + onChanged: (_) => blogPostCubit.clearVideoUrlError(), + ), + if (state.videoUrlError != null) ...[ + const SizedBox(height: 4), + ValidationErrorWidget(error: state.videoUrlError!), + ], + if (state.formError != null) ...[ + const SizedBox(height: 16), + ValidationErrorWidget(error: state.formError!), + ], + const SizedBox(height: 80), + _createBlogPostButton(isSubmitting: state.isLoading), + const SizedBox(height: 80), + ], + ), + ); + }, + ), + ); + } + + Future _submitBlogPost() async { + if (blogPostCubit.state.isLoading) { + return; + } + + final title = titleController.text.trim(); + final description = descriptionController.text.trim(); + final videoUrl = videoUrlController.text.trim(); + final existingMediaIds = savedImages.map((image) => image.id!).toList(); + + final bool result; + if (_isEditMode) { + result = await blogPostCubit.updateBlogPost( + id: widget.feedBlogPostModel!.id, + title: title, + description: description, + videoUrl: videoUrl, + existingMediaIds: existingMediaIds, + newImages: pendingImages, + mediaIdsToDelete: List.from(mediaIdsToDelete), + ); + } else { + result = await blogPostCubit.createBlogPost( + title: title, + description: description, + videoUrl: videoUrl, + images: pendingImages, + ); + } + + if (!result) { + if (!mounted) return; + if (blogPostCubit.state.hasValidationErrors) { + return; + } + showErrorDialog( + context, + title: _isEditMode + ? S.current.could_not_update_blog_post + : S.current.could_not_publish_blog_post, + ); + } else { + if (!mounted) return; + if (context.canPop()) { + context.pop(); + } + } + } + + Widget _createBlogPostButton({required bool isSubmitting}) { + return InkWell( + key: AppKeys.blogPostCreateScreenCreateButton, + onTap: isSubmitting ? null : _submitBlogPost, + borderRadius: BorderRadius.circular(16), + child: isSubmitting + ? const Center(child: Loader()) + : Ink( + width: MediaQuery.of(context).size.width - 32, + height: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: appTheme.blueA400, + ), + child: Center( + child: Text( + _isEditMode ? 'Сохранить' : 'Создать', + style: const TextStyle(color: Colors.white), + ), + ), + ), + ); + } + + Widget _buildGallery() { + final itemHeight = shrinkImage ? 72.0 : 112.0; + + return SizedBox( + height: itemHeight, + child: ReorderableListView.builder( + scrollDirection: Axis.horizontal, + buildDefaultDragHandles: false, + onReorder: _onReorderGallery, + itemCount: _galleryCount + (_remainingSlots > 0 ? 1 : 0), + itemBuilder: (context, index) { + if (index == _galleryCount) { + return KeyedSubtree( + key: const ValueKey('add-photo'), + child: _buildAddPhotoTile(itemHeight), + ); + } + + if (index < savedImages.length) { + final image = savedImages[index]; + final url = PostFeedMapper.resolveMediaUrl(image.url); + return KeyedSubtree( + key: ValueKey('saved-${image.id}'), + child: _buildGalleryTile( + index: index, + height: itemHeight, + imageProvider: NetworkImage(url), + onRemove: () => _removeSavedImage(index), + ), + ); + } + + final pendingIndex = index - savedImages.length; + final pending = pendingImages[pendingIndex]; + return KeyedSubtree( + key: ValueKey('pending-${pending.path}'), + child: _buildGalleryTile( + index: index, + height: itemHeight, + imageProvider: FileImage(File(pending.path)), + onRemove: () => _removePendingImage(pendingIndex), + ), + ); + }, + ), + ); + } + + Widget _buildAddPhotoTile(double height) { + return GestureDetector( + onTap: _showImagePicker, + child: Container( + width: height, + height: height, + margin: const EdgeInsets.only(right: 8), + decoration: AppDecoration.fillGray.copyWith( + borderRadius: BorderRadius.circular(16), + ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ - const SizedBox(height: 16), - const Text('Заголовок'), - const SizedBox(height: 8), - CustomTextFormField(key: AppKeys.blogPostCreateScreenTitleTextField, controller: titleController), - const SizedBox(height: 16), - const Text('Описание'), - const SizedBox(height: 8), - isPreviewMode - ? _buildMarkdownPreview() - : CustomTextFormField( - key: AppKeys.blogPostCreateScreenContentTextField, - controller: descriptionController, - focusNode: descriptionFocusNode, - maxLines: 6, - minLines: 4, - ), - const SizedBox(height: 16), - if (videoUrlController.text.isEmpty) const Text('Изображение поста'), - if (videoUrlController.text.isEmpty) const SizedBox(height: 8), - if (videoUrlController.text.isEmpty) _buildAddPhoto(), - const SizedBox(height: 16), - if (image == null && widget.feedBlogPostModel?.image == null) const Text('Ссылка на видео'), - if (image == null && widget.feedBlogPostModel?.image == null) const SizedBox(height: 8), - if (image == null && widget.feedBlogPostModel?.image == null) - CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController), - const SizedBox(height: 80), - _createBlogPostButton(), - const SizedBox(height: 80), + Icon(Icons.photo_library, color: appTheme.blueGray300), + const SizedBox(height: 4), + Text( + 'Добавить', + style: TextStyle(fontSize: 12, color: appTheme.blueGray300), + ), ], ), ), ); } - Widget _createBlogPostButton() { - return InkWell( - onTap: isLoading - ? null - : () async { - setState(() { - isLoading = true; - }); - if (widget.feedBlogPostModel != null) { - final result = await blogPostCubit.updateBlogPost( - id: widget.feedBlogPostModel!.id, - title: titleController.text, - description: descriptionController.text, - videoUrl: videoUrlController.text, - image: image, - oldImageUrl: widget.feedBlogPostModel!.image, - ); - setState(() { - isLoading = false; - }); - if (!result) { - if (!mounted) return; - showErrorDialog(context, title: S.current.could_not_update_blog_post); - } else { - if (!mounted) return; - if (context.canPop()) { - context.pop(); - } - } - } else { - final result = await blogPostCubit.createBlogPost( - title: titleController.text, - description: descriptionController.text, - videoUrl: videoUrlController.text, - image: image, - ); - setState(() { - isLoading = false; - }); - if (!result) { - if (!mounted) return; - showErrorDialog(context, title: S.current.could_not_publish_blog_post); - } else { - if (!mounted) return; - if (context.canPop()) { - context.pop(); - } - } - } - }, - borderRadius: BorderRadius.circular(16), - child: isLoading - ? const Center(child: Loader()) - : Ink( - width: MediaQuery.of(context).size.width - 32, - height: 52, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: appTheme.blueA400), - child: const Center( - child: Text('Создать', style: TextStyle(color: Colors.white)), - ), - ), - ); - } - - GestureDetector _buildAddPhoto() { - return GestureDetector( - onTap: () { - _showImagePicker(); - }, - child: Stack( - children: [ - if (image == null && widget.feedBlogPostModel?.image == null) - AnimatedContainer( - height: shrinkImage ? 52 : 128, - curve: Curves.easeIn, - duration: const Duration(milliseconds: 150), - decoration: AppDecoration.fillGray.copyWith(borderRadius: BorderRadius.circular(16)), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.photo_library, color: appTheme.blueGray300), - const SizedBox(width: 8), - Text("Добавить фото", selectionColor: appTheme.blueA400), - ], - ), - ), - if (image != null) - SizedBox( - width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32, + Widget _buildGalleryTile({ + required int index, + required double height, + required ImageProvider imageProvider, + required VoidCallback onRemove, + }) { + return ReorderableDelayedDragStartListener( + index: index, + child: Container( + width: height, + height: height, + margin: const EdgeInsets.only(right: 8), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular(16), - child: Image(image: FileImage(File(image!.path))), + child: Image(image: imageProvider, fit: BoxFit.cover), ), ), - if (widget.feedBlogPostModel?.image != null && image == null) - SizedBox( - width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Image(image: NetworkImage('${sl().baseUrl}/storage/${widget.feedBlogPostModel!.image}')), + Positioned( + top: -6, + right: -6, + child: Material( + color: Colors.white, + shape: const CircleBorder(), + elevation: 1, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onRemove, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(Icons.close, size: 16), + ), + ), ), ), - ], + ], + ), ), ); } diff --git a/lib/features/blog_post_create/repositories/blog_post_repository.dart b/lib/features/blog_post_create/repositories/blog_post_repository.dart index 337854dc..6cd544a6 100644 --- a/lib/features/blog_post_create/repositories/blog_post_repository.dart +++ b/lib/features/blog_post_create/repositories/blog_post_repository.dart @@ -1,79 +1,122 @@ import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:eatvio_app/core/app_config.dart'; -import 'package:eatvio_app/core/locator_service.dart'; -import 'package:http/http.dart' as http; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; -import 'package:path_provider/path_provider.dart'; class BlogPostRepository { + static const maxGalleryImages = 10; + final BlogPostService blogPostService; - BlogPostRepository({ - required this.blogPostService, - }); + BlogPostRepository({required this.blogPostService}); - Future createBlogPost( + Future createBlogPost({ String? title, String? videoUrl, String? description, - XFile? image, - ) async { - final formData = FormData.fromMap({ - if (title != null && title.isNotEmpty) 'title': title, - if (description != null && description.isNotEmpty) 'description_md': description, - if (videoUrl != null && videoUrl.isNotEmpty) 'video_url': videoUrl, - if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name), - }); - - return blogPostService.createBlogPost( - formData: formData, + List images = const [], + }) async { + final formData = await _buildFormData( + title: title, + videoUrl: videoUrl, + description: description, + newImages: images, ); + + return blogPostService.createBlogPost(formData: formData); } - Future downloadImage(String imageUrl) async { - final response = await http.get(Uri.parse('${sl().baseUrl}/storage/$imageUrl')); - - if (response.statusCode == 200) { - final directory = await getTemporaryDirectory(); - final filePath = '${directory.path}/image.jpg'; - final file = File(filePath); - await file.writeAsBytes(response.bodyBytes); - - return XFile(file.path); + Future updateBlogPost({ + required int id, + String? title, + String? videoUrl, + String? description, + List existingMediaIds = const [], + List newImages = const [], + List mediaIdsToDelete = const [], + }) async { + if (mediaIdsToDelete.isNotEmpty) { + await Future.wait( + mediaIdsToDelete.map( + (mediaId) => + blogPostService.deleteBlogPostMedia(postId: id, mediaId: mediaId), + ), + ); } - return null; + + final formData = await _buildFormData( + title: title ?? '', + videoUrl: videoUrl ?? '', + description: description ?? '', + existingMediaIds: existingMediaIds, + newImages: newImages, + includeExistingMediaIds: true, + includeEmptyTextFields: true, + ); + + return blogPostService.updateBlogPost(id: id, formData: formData); } - Future updateBlogPost( - int id, - String? title, - String? videoUrl, - String? description, - XFile? image, - String? oldImageUrl, - ) async { - final formData = FormData.fromMap({ - 'title': title, - 'description_md': description, - 'video_url': videoUrl, - 'image': image != null - ? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name) - : oldImageUrl != '' && oldImageUrl != null - ? await MultipartFile.fromFile((await downloadImage(oldImageUrl))?.path ?? '', filename: oldImageUrl.split('/').last) - : null, - }); - - return blogPostService.updateBlogPost( - id: id, - formData: formData, - ); + Future deleteBlogPostMedia({ + required int postId, + required int mediaId, + }) async { + await blogPostService.deleteBlogPostMedia(postId: postId, mediaId: mediaId); } Future deleteBlogPost(int id) async { await blogPostService.deleteBlogPost(id: id); } + + Future _buildFormData({ + String? title, + String? videoUrl, + String? description, + List existingMediaIds = const [], + List newImages = const [], + bool includeExistingMediaIds = false, + bool includeEmptyTextFields = false, + }) async { + final formData = FormData(); + + if (includeEmptyTextFields) { + formData.fields.add(MapEntry('title', title ?? '')); + formData.fields.add(MapEntry('description_md', description ?? '')); + formData.fields.add(MapEntry('video_url', videoUrl ?? '')); + } else { + if (title != null && title.isNotEmpty) { + formData.fields.add(MapEntry('title', title)); + } + if (description != null && description.isNotEmpty) { + formData.fields.add(MapEntry('description_md', description)); + } + if (videoUrl != null && videoUrl.isNotEmpty) { + formData.fields.add(MapEntry('video_url', videoUrl)); + } + } + + if (includeExistingMediaIds) { + for (final mediaId in existingMediaIds) { + formData.fields.add( + MapEntry('existing_media_ids[]', mediaId.toString()), + ); + } + } + + for (final image in newImages) { + final compressed = await ImageCompressUtility.compressFile( + File(image.path), + ); + formData.files.add( + MapEntry( + 'images[]', + await MultipartFile.fromFile(compressed.path, filename: image.name), + ), + ); + } + + return formData; + } } diff --git a/lib/features/blog_post_create/services/blog_post_service.dart b/lib/features/blog_post_create/services/blog_post_service.dart index 56fc5461..72c0be12 100644 --- a/lib/features/blog_post_create/services/blog_post_service.dart +++ b/lib/features/blog_post_create/services/blog_post_service.dart @@ -7,21 +7,23 @@ part 'blog_post_service.g.dart'; abstract class BlogPostService { factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService; - @POST('/api/blogPosts/create/published') + @POST('/api/v2/blogPosts/create/published') @MultiPart() - Future createBlogPost({ - @Body() required FormData formData, - }); + Future createBlogPost({@Body() required FormData formData}); - @POST('/api/blogPosts/update/published/{id}') + @PUT('/api/v2/blogPosts/update/published/{id}') @MultiPart() Future updateBlogPost({ @Path('id') required int id, @Body() required FormData formData, }); - @DELETE('/api/blogPosts/delete/{id}') - Future deleteBlogPost({ - @Path('id') required int id, + @DELETE('/api/v2/blogPosts/{postId}/media/{mediaId}') + Future deleteBlogPostMedia({ + @Path('postId') required int postId, + @Path('mediaId') required int mediaId, }); + + @DELETE('/api/blogPosts/delete/{id}') + Future deleteBlogPost({@Path('id') required int id}); } diff --git a/lib/features/blog_post_create/validation/blog_post_form_validator.dart b/lib/features/blog_post_create/validation/blog_post_form_validator.dart new file mode 100644 index 00000000..bb4fbb86 --- /dev/null +++ b/lib/features/blog_post_create/validation/blog_post_form_validator.dart @@ -0,0 +1,158 @@ +/// Client-side validation aligned with backend BlogPost V2 create/update rules. +abstract final class BlogPostFormValidator { + static const minTextLength = 3; + static const maxVideoUrlLength = 2048; + static const maxGalleryImages = 10; + static const allowedImageExtensions = {'jpg', 'jpeg', 'png'}; + + static const titleTooShort = 'Заголовок должен содержать минимум 3 символа'; + static const descriptionTooShort = + 'Описание должно содержать минимум 3 символа'; + static const contentRequired = + 'Добавьте заголовок, описание, изображение или ссылку на видео'; + static const galleryTooLarge = 'Можно загрузить не более 10 изображений'; + static const imageTypeInvalid = 'Допустимы только изображения JPG или PNG'; + static const videoUrlTooLong = 'Ссылка на видео слишком длинная'; + static const videoUrlInvalid = 'Укажите корректную ссылку на видео'; + static const videoUrlUnsupportedHost = + 'Поддерживаются только Rutube, VK и OK'; + static const videoUrlRutubeFormat = 'Неверный формат ссылки Rutube'; + static const videoUrlVkFormat = 'Неверный формат ссылки VK'; + static const videoUrlOkFormat = 'Неверный формат ссылки OK'; + + /// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim). + static String? title(String? value) { + final text = value?.trim() ?? ''; + if (text.isEmpty) { + return null; + } + if (text.length < minTextLength) { + return titleTooShort; + } + return null; + } + + /// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim). + static String? description(String? value) { + final text = value?.trim() ?? ''; + if (text.isEmpty) { + return null; + } + if (text.length < minTextLength) { + return descriptionTooShort; + } + return null; + } + + /// Empty is allowed; non-empty must match allowed video hosts/paths. + static String? videoUrl(String? value) { + final url = value?.trim() ?? ''; + if (url.isEmpty) { + return null; + } + if (url.length > maxVideoUrlLength) { + return videoUrlTooLong; + } + + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) { + return videoUrlInvalid; + } + if (uri.scheme != 'http' && uri.scheme != 'https') { + return videoUrlInvalid; + } + + final host = uri.host.toLowerCase(); + if (!_hostMatchesRootAllowlist(host, const [ + 'vk.com', + 'vkvideo.ru', + 'ok.ru', + 'rutube.ru', + ])) { + return videoUrlUnsupportedHost; + } + + final path = uri.path; + if (_hostMatchesRootAllowlist(host, const ['rutube.ru'])) { + if (!_rutubePath.hasMatch(path)) { + return videoUrlRutubeFormat; + } + return null; + } + if (_hostMatchesRootAllowlist(host, const ['vk.com', 'vkvideo.ru'])) { + if (!_vkPath.hasMatch(path)) { + return videoUrlVkFormat; + } + return null; + } + if (_hostMatchesRootAllowlist(host, const ['ok.ru'])) { + if (!_okPath.hasMatch(path)) { + return videoUrlOkFormat; + } + } + return null; + } + + /// At least one of title / description / images / video must be meaningfully filled. + static String? contentRequiredMessage({ + required String title, + required String description, + required String videoUrl, + required int imageCount, + }) { + final hasTitle = title.trim().length >= minTextLength; + final hasDescription = description.trim().length >= minTextLength; + final trimmedVideo = videoUrl.trim(); + final hasVideo = + trimmedVideo.isNotEmpty && + BlogPostFormValidator.videoUrl(trimmedVideo) == null; + final hasImages = imageCount > 0; + + if (hasTitle || hasDescription || hasVideo || hasImages) { + return null; + } + return contentRequired; + } + + static String? galleryCount(int count) { + if (count > maxGalleryImages) { + return galleryTooLarge; + } + return null; + } + + /// Returns error if any path has a non-allowed extension. Empty path is ignored. + static String? imagePaths(Iterable paths) { + for (final path in paths) { + final name = path.split(RegExp(r'[\\/]')).last; + final dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length - 1) { + return imageTypeInvalid; + } + final ext = name.substring(dot + 1).toLowerCase(); + if (!allowedImageExtensions.contains(ext)) { + return imageTypeInvalid; + } + } + return null; + } + + static final _rutubePath = RegExp( + r'^/(video|shorts)/[a-z0-9]+/?$', + caseSensitive: false, + ); + static final _vkPath = RegExp(r'^/video-?\d+_\d+/?$', caseSensitive: false); + static final _okPath = RegExp(r'^/video/\d+/?$'); + + static bool _hostMatchesRootAllowlist( + String host, + List allowedRootHosts, + ) { + for (final allowed in allowedRootHosts) { + if (host == allowed || host.endsWith('.$allowed')) { + return true; + } + } + return false; + } +} 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 db3bf384..2801c2b4 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart @@ -19,6 +19,9 @@ class ProfileCubit extends Cubit { final ConsultantRepository consultantRepository; String _currentPeriod = '1'; + /// Bottom-nav / own profile instance, used to refresh posts after create/update/delete. + static ProfileCubit? myProfileCubit; + ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading()); Future getProfileBySlug(String slug) async { @@ -49,6 +52,7 @@ class ProfileCubit extends Cubit { } Future getMyProfile({String period = '1'}) async { + myProfileCubit = this; emit(const ProfileState.loading()); _currentPeriod = period; try { @@ -74,6 +78,22 @@ class ProfileCubit extends Cubit { } } + /// Reloads only the blog posts list without resetting the whole profile screen. + Future reloadBlogPosts() async { + final currentState = state; + if (currentState is! ProfileStateLoaded) { + return; + } + + try { + final blogPosts = await profileRepository.getBlogPostsByAuthorSlug(slug: currentState.profile.slug); + emit(currentState.copyWith(blogPosts: blogPosts)); + } catch (e, st) { + await reportFrom(e, st, this); + log(e.toString(), stackTrace: st); + } + } + void updateFeedBlogPostList(FeedBlogPostModel feedBlogPostModel, bool wasLikedByMe) { List feedBlogPostList = [...(state as ProfileStateLoaded).blogPosts]; final int index = feedBlogPostList.indexOf(feedBlogPostModel); diff --git a/lib/features/profile/presentation/pages/profile_screen.dart b/lib/features/profile/presentation/pages/profile_screen.dart index 7d4359f8..c7dae0f5 100644 --- a/lib/features/profile/presentation/pages/profile_screen.dart +++ b/lib/features/profile/presentation/pages/profile_screen.dart @@ -436,8 +436,11 @@ class _ProfileScreenState extends State { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: InkWell( - onTap: () { - GoRouter.of(context).pushNamed('blogPostCreateScreen'); + onTap: () async { + await GoRouter.of(context).pushNamed('blogPostCreateScreen'); + if (mounted) { + await profileCubit.reloadBlogPosts(); + } }, borderRadius: BorderRadius.circular(8), child: Ink( diff --git a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart index 9ae218cc..2f19f12a 100644 --- a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart @@ -101,6 +101,7 @@ class _ProfileBlogPostWidgetState extends State { id: post.id, feedBlogPostModel: post, userSlug: post.author.authorSlug, + onPostsChanged: widget.profileCubit.reloadBlogPosts, ), ], ), diff --git a/test/features/blog_post_create/advanced_widget_test.dart b/test/features/blog_post_create/advanced_widget_test.dart index e052fa3c..aafb9aa8 100644 --- a/test/features/blog_post_create/advanced_widget_test.dart +++ b/test/features/blog_post_create/advanced_widget_test.dart @@ -21,10 +21,18 @@ void main() { setUp(() { blogPostRepository = FixtureBlogPostRepository(); - blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository); - fullCreateFixture = FixtureReader.readAsMap('blog_post_create/create_request_full.json'); - updateFixture = FixtureReader.readAsMap('blog_post_create/update_request.json'); - deleteFixture = FixtureReader.readAsMap('blog_post_create/delete_request.json'); + blogPostCreateCubit = BlogPostCreateCubit( + blogPostRepository: blogPostRepository, + ); + fullCreateFixture = FixtureReader.readAsMap( + 'blog_post_create/create_request_full.json', + ); + updateFixture = FixtureReader.readAsMap( + 'blog_post_create/update_request.json', + ); + deleteFixture = FixtureReader.readAsMap( + 'blog_post_create/delete_request.json', + ); }); tearDown(() { @@ -41,7 +49,9 @@ void main() { ); } - testWidgets('should handle blog post creation with all fields', (WidgetTester tester) async { + testWidgets('should handle blog post creation with all fields', ( + WidgetTester tester, + ) async { // Arrange Widget formWidget = Column( children: [ @@ -69,7 +79,6 @@ void main() { title: fullCreateFixture['title'] as String?, description: fullCreateFixture['description_md'] as String?, videoUrl: fullCreateFixture['video_url'] as String?, - image: null, ); }, child: const Text('Опубликовать'), @@ -81,9 +90,18 @@ void main() { await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + fullCreateFixture['title'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + fullCreateFixture['description_md'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + fullCreateFixture['video_url'] as String, + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); @@ -96,7 +114,9 @@ void main() { ); }); - testWidgets('should handle blog post creation with image', (WidgetTester tester) async { + testWidgets('should handle blog post creation with image', ( + WidgetTester tester, + ) async { // Arrange final expectedPayload = Map.from(fullCreateFixture); expectedPayload['video_url'] = null; @@ -129,7 +149,11 @@ void main() { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), + Icon( + Icons.add_photo_alternate, + size: 48, + color: Colors.grey, + ), SizedBox(height: 8), Text('Добавить изображение'), ], @@ -145,7 +169,6 @@ void main() { title: fullCreateFixture['title'] as String?, description: fullCreateFixture['description_md'] as String?, videoUrl: null, - image: null, // В реальном тесте здесь был бы XFile ); }, child: const Text('Опубликовать'), @@ -157,15 +180,24 @@ void main() { await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + fullCreateFixture['title'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + fullCreateFixture['description_md'] as String, + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.pump(); // Assert - expect(blogPostRepository.createPayloads.single.toJson(), expectedPayload); + expect( + blogPostRepository.createPayloads.single.toJson(), + expectedPayload, + ); }); testWidgets('should handle blog post update', (WidgetTester tester) async { @@ -175,7 +207,12 @@ void main() { state: 'published', image: 'original_image.jpg', description: 'Original Description', - author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true), + author: const Author( + authorSlug: 'test-author', + authorName: 'Test Author', + authorProfileIcon: 'avatar.jpg', + authorIsMe: true, + ), videoUrl: 'https://youtube.com/watch?v=original', createdAt: DateTime.now(), updatedAt: DateTime.now(), @@ -216,8 +253,9 @@ void main() { title: updateFixture['title'] as String?, description: updateFixture['description_md'] as String?, videoUrl: updateFixture['video_url'] as String?, - image: null, - oldImageUrl: updateFixture['old_image_url'] as String?, + existingMediaIds: List.from( + updateFixture['existing_media_ids'] as List, + ), ); }, child: const Text('Обновить'), @@ -229,9 +267,18 @@ void main() { await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), updateFixture['title'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), updateFixture['description_md'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), updateFixture['video_url'] as String); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + updateFixture['title'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + updateFixture['description_md'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + updateFixture['video_url'] as String, + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); @@ -241,7 +288,9 @@ void main() { expect(blogPostRepository.updatePayloads.single.toJson(), updateFixture); }); - testWidgets('should handle blog post deletion', (WidgetTester tester) async { + testWidgets('should handle blog post deletion', ( + WidgetTester tester, + ) async { // Arrange Widget deleteWidget = ElevatedButton( key: const Key('deleteBlogPostButton'), @@ -262,18 +311,21 @@ void main() { expect(blogPostRepository.deleteCalls.single, deleteFixture['id']); }); - testWidgets('should handle loading state during blog post creation', (WidgetTester tester) async { + testWidgets('should handle loading state during blog post creation', ( + WidgetTester tester, + ) async { // Arrange blogPostRepository.createDelay = const Duration(milliseconds: 50); - Widget loadingWidget = BlocBuilder( - builder: (context, state) { - if (state is BlogPostCreateStateLoading) { - return const Center(child: CircularProgressIndicator()); - } - return const Text('Not Loading'); - }, - ); + Widget loadingWidget = + BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + return const Text('Not Loading'); + }, + ); // Act await tester.pumpWidget(createTestWidget(loadingWidget)); @@ -284,7 +336,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: null, description: fullCreateFixture['description_md'] as String?, - image: null, ); await tester.pump(); await tester.pump(); // Need to pump twice to get the loading state @@ -297,18 +348,21 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('should handle error state during blog post creation', (WidgetTester tester) async { + testWidgets('should handle error state during blog post creation', ( + WidgetTester tester, + ) async { // Arrange blogPostRepository.createError = Exception('Network error'); - Widget errorWidget = BlocBuilder( - builder: (context, state) { - if (state is BlogPostCreateStateError) { - return Center(child: Text('Error: ${state.errorMessage}')); - } - return const Text('No Error'); - }, - ); + Widget errorWidget = + BlocBuilder( + builder: (context, state) { + if (state.errorMessage != null) { + return Center(child: Text('Error: ${state.errorMessage}')); + } + return const Text('No Error'); + }, + ); // Act await tester.pumpWidget(createTestWidget(errorWidget)); @@ -319,7 +373,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: null, description: fullCreateFixture['description_md'] as String?, - image: null, ); await tester.pump(); await tester.pump(); // Need to pump twice to get the error state @@ -328,16 +381,21 @@ void main() { expect(find.textContaining('Error:'), findsOneWidget); }); - testWidgets('should handle success state after blog post creation', (WidgetTester tester) async { + testWidgets('should handle success state after blog post creation', ( + WidgetTester tester, + ) async { // Arrange - Widget successWidget = BlocBuilder( - builder: (context, state) { - if (state is BlogPostCreateStateLoaded) { - return const Center(child: Text('Blog post created successfully!')); - } - return const Text('Not Loaded'); - }, - ); + Widget successWidget = + BlocBuilder( + builder: (context, state) { + if (state.isSuccess) { + return const Center( + child: Text('Blog post created successfully!'), + ); + } + return const Text('Not Loaded'); + }, + ); // Act await tester.pumpWidget(createTestWidget(successWidget)); @@ -348,7 +406,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: null, description: fullCreateFixture['description_md'] as String?, - image: null, ); await tester.pump(); await tester.pump(); // Need to pump twice to get the success state @@ -357,23 +414,35 @@ void main() { expect(find.text('Blog post created successfully!'), findsOneWidget); }); - testWidgets('should validate form fields before submission', (WidgetTester tester) async { + testWidgets('should validate form fields before submission', ( + WidgetTester tester, + ) async { // Arrange Widget validationWidget = Column( children: [ TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок не может быть пустым'), + decoration: const InputDecoration( + labelText: 'Заголовок', + errorText: 'Заголовок не может быть пустым', + ), ), const SizedBox(height: 16), TextFormField( key: AppKeys.blogPostCreateScreenContentTextField, - decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание не может быть пустым'), + decoration: const InputDecoration( + labelText: 'Описание', + errorText: 'Описание не может быть пустым', + ), maxLines: 6, minLines: 4, ), const SizedBox(height: 16), - ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ), ], ); @@ -386,7 +455,9 @@ void main() { expect(find.text('Описание не может быть пустым'), findsOneWidget); }); - testWidgets('should handle image picker interaction', (WidgetTester tester) async { + testWidgets('should handle image picker interaction', ( + WidgetTester tester, + ) async { // Arrange bool imagePickerCalled = false; Widget imagePickerWidget = GestureDetector( @@ -425,16 +496,25 @@ void main() { expect(imagePickerCalled, isTrue); }); - testWidgets('should handle video URL validation', (WidgetTester tester) async { + testWidgets('should handle video URL validation', ( + WidgetTester tester, + ) async { // Arrange Widget videoValidationWidget = Column( children: [ TextFormField( key: AppKeys.blogPostCreateScreenVideoLinkTextField, - decoration: const InputDecoration(labelText: 'Ссылка на видео', errorText: 'Введите корректную ссылку на видео'), + decoration: const InputDecoration( + labelText: 'Ссылка на видео', + errorText: 'Введите корректную ссылку на видео', + ), ), const SizedBox(height: 16), - ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ), ], ); @@ -446,7 +526,9 @@ void main() { expect(find.text('Введите корректную ссылку на видео'), findsOneWidget); }); - testWidgets('should handle form submission with empty fields', (WidgetTester tester) async { + testWidgets('should handle form submission with empty fields', ( + WidgetTester tester, + ) async { // Arrange Widget emptyFormWidget = Column( children: [ @@ -465,7 +547,11 @@ void main() { ElevatedButton( key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () async { - await blogPostCreateCubit.createBlogPost(title: '', videoUrl: '', description: '', image: null); + await blogPostCreateCubit.createBlogPost( + title: '', + videoUrl: '', + description: '', + ); }, child: const Text('Опубликовать'), ), @@ -479,14 +565,14 @@ void main() { await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.pump(); - // Assert - expect( - blogPostRepository.createPayloads.single.toJson(), - {'title': '', 'description_md': '', 'video_url': '', 'image_name': null}, - ); + // Assert — invalid empty payload must not reach the repository + expect(blogPostRepository.createPayloads, isEmpty); + expect(blogPostCreateCubit.state.hasValidationErrors, isTrue); }); - testWidgets('should handle blog post creation with long content', (WidgetTester tester) async { + testWidgets('should handle blog post creation with long content', ( + WidgetTester tester, + ) async { // Arrange const longContent = 'Это очень длинное описание блог поста, которое содержит много текста и должно быть правильно обработано системой. ' @@ -510,7 +596,11 @@ void main() { ElevatedButton( key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () async { - await blogPostCreateCubit.createBlogPost(title: 'Test Title', videoUrl: null, description: longContent, image: null); + await blogPostCreateCubit.createBlogPost( + title: 'Test Title', + videoUrl: null, + description: longContent, + ); }, child: const Text('Опубликовать'), ), @@ -521,23 +611,26 @@ void main() { await tester.pumpWidget(createTestWidget(longContentWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'Test Title'); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), longContent); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + 'Test Title', + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + longContent, + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.pump(); // Assert - expect( - blogPostRepository.createPayloads.single.toJson(), - { - 'title': 'Test Title', - 'description_md': longContent, - 'video_url': null, - 'image_name': null, - }, - ); + expect(blogPostRepository.createPayloads.single.toJson(), { + 'title': 'Test Title', + 'description_md': longContent, + 'video_url': null, + 'image_name': null, + }); }); }); } diff --git a/test/features/blog_post_create/basic_widget_test.dart b/test/features/blog_post_create/basic_widget_test.dart index 1e91998f..3cd4aa38 100644 --- a/test/features/blog_post_create/basic_widget_test.dart +++ b/test/features/blog_post_create/basic_widget_test.dart @@ -14,7 +14,9 @@ void main() { setUp(() { blogPostRepository = FixtureBlogPostRepository(); - blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository); + blogPostCreateCubit = BlogPostCreateCubit( + blogPostRepository: blogPostRepository, + ); }); tearDown(() { @@ -31,7 +33,9 @@ void main() { ); } - testWidgets('should create BlogPostCreateCubit successfully', (WidgetTester tester) async { + testWidgets('should create BlogPostCreateCubit successfully', ( + WidgetTester tester, + ) async { // Arrange & Act final cubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository); @@ -41,13 +45,18 @@ void main() { cubit.close(); }); - testWidgets('should display basic blog post form fields', (WidgetTester tester) async { + testWidgets('should display basic blog post form fields', ( + WidgetTester tester, + ) async { // Arrange Widget formWidget = Column( children: [ TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'), + decoration: const InputDecoration( + labelText: 'Заголовок', + hintText: 'Введите заголовок поста', + ), ), const SizedBox(height: 16), TextFormField( @@ -62,7 +71,11 @@ void main() { decoration: const InputDecoration(labelText: 'Ссылка на видео'), ), const SizedBox(height: 16), - ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ), ], ); @@ -71,17 +84,34 @@ void main() { await tester.pumpAndSettle(); // Assert - expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenCreateButton), + findsOneWidget, + ); }); - testWidgets('should update title when user types in title field', (WidgetTester tester) async { + testWidgets('should update title when user types in title field', ( + WidgetTester tester, + ) async { // Arrange Widget formWidget = TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'), + decoration: const InputDecoration( + labelText: 'Заголовок', + hintText: 'Введите заголовок поста', + ), ); await tester.pumpWidget(createTestWidget(formWidget)); @@ -90,36 +120,47 @@ void main() { const testTitle = 'Тестовый заголовок поста'; // Act - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), testTitle); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + testTitle, + ); await tester.pump(); // Assert expect(find.text(testTitle), findsOneWidget); }); - testWidgets('should update description when user types in description field', (WidgetTester tester) async { - // Arrange - Widget formWidget = TextFormField( - key: AppKeys.blogPostCreateScreenContentTextField, - decoration: const InputDecoration(labelText: 'Описание'), - maxLines: 6, - minLines: 4, - ); + testWidgets( + 'should update description when user types in description field', + (WidgetTester tester) async { + // Arrange + Widget formWidget = TextFormField( + key: AppKeys.blogPostCreateScreenContentTextField, + decoration: const InputDecoration(labelText: 'Описание'), + maxLines: 6, + minLines: 4, + ); - await tester.pumpWidget(createTestWidget(formWidget)); - await tester.pumpAndSettle(); + await tester.pumpWidget(createTestWidget(formWidget)); + await tester.pumpAndSettle(); - const testDescription = 'Это тестовое описание блог поста'; + const testDescription = 'Это тестовое описание блог поста'; - // Act - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), testDescription); - await tester.pump(); + // Act + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + testDescription, + ); + await tester.pump(); - // Assert - expect(find.text(testDescription), findsOneWidget); - }); + // Assert + expect(find.text(testDescription), findsOneWidget); + }, + ); - testWidgets('should update video URL when user types in video field', (WidgetTester tester) async { + testWidgets('should update video URL when user types in video field', ( + WidgetTester tester, + ) async { // Arrange Widget formWidget = TextFormField( key: AppKeys.blogPostCreateScreenVideoLinkTextField, @@ -132,16 +173,27 @@ void main() { const testVideoUrl = 'https://youtube.com/watch?v=example'; // Act - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), testVideoUrl); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + testVideoUrl, + ); await tester.pump(); // Assert expect(find.text(testVideoUrl), findsOneWidget); }); - testWidgets('should display form labels correctly', (WidgetTester tester) async { + testWidgets('should display form labels correctly', ( + WidgetTester tester, + ) async { // Arrange - Widget formWidget = const Column(children: [Text('Заголовок'), Text('Описание'), Text('Ссылка на видео')]); + Widget formWidget = const Column( + children: [ + Text('Заголовок'), + Text('Описание'), + Text('Ссылка на видео'), + ], + ); // Act await tester.pumpWidget(createTestWidget(formWidget)); @@ -153,11 +205,16 @@ void main() { expect(find.text('Ссылка на видео'), findsOneWidget); }); - testWidgets('should display placeholder text in title field', (WidgetTester tester) async { + testWidgets('should display placeholder text in title field', ( + WidgetTester tester, + ) async { // Arrange Widget formWidget = TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'), + decoration: const InputDecoration( + labelText: 'Заголовок', + hintText: 'Введите заголовок поста', + ), ); // Act @@ -170,14 +227,21 @@ void main() { testWidgets('should display create button', (WidgetTester tester) async { // Arrange - Widget formWidget = ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')); + Widget formWidget = ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ); // Act await tester.pumpWidget(createTestWidget(formWidget)); await tester.pumpAndSettle(); // Assert - expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenCreateButton), + findsOneWidget, + ); expect(find.text('Опубликовать'), findsOneWidget); }); @@ -217,19 +281,26 @@ void main() { testWidgets('should display error message', (WidgetTester tester) async { // Arrange - Widget errorWidget = const Center(child: Text('Произошла ошибка при создании блог поста')); + Widget errorWidget = const Center( + child: Text('Произошла ошибка при создании блог поста'), + ); // Act await tester.pumpWidget(createTestWidget(errorWidget)); await tester.pumpAndSettle(); // Assert - expect(find.text('Произошла ошибка при создании блог поста'), findsOneWidget); + expect( + find.text('Произошла ошибка при создании блог поста'), + findsOneWidget, + ); }); testWidgets('should display success message', (WidgetTester tester) async { // Arrange - Widget successWidget = const Center(child: Text('Блог пост успешно создан!')); + Widget successWidget = const Center( + child: Text('Блог пост успешно создан!'), + ); // Act await tester.pumpWidget(createTestWidget(successWidget)); @@ -245,12 +316,18 @@ void main() { children: [ TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок обязателен'), + decoration: const InputDecoration( + labelText: 'Заголовок', + errorText: 'Заголовок обязателен', + ), ), const SizedBox(height: 16), TextFormField( key: AppKeys.blogPostCreateScreenContentTextField, - decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание обязательно'), + decoration: const InputDecoration( + labelText: 'Описание', + errorText: 'Описание обязательно', + ), ), ], ); @@ -264,13 +341,18 @@ void main() { expect(find.text('Описание обязательно'), findsOneWidget); }); - testWidgets('should display form with all required fields', (WidgetTester tester) async { + testWidgets('should display form with all required fields', ( + WidgetTester tester, + ) async { // Arrange Widget completeForm = Column( children: [ TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'), + decoration: const InputDecoration( + labelText: 'Заголовок', + hintText: 'Введите заголовок поста', + ), ), const SizedBox(height: 16), TextFormField( @@ -285,7 +367,11 @@ void main() { decoration: const InputDecoration(labelText: 'Ссылка на видео'), ), const SizedBox(height: 32), - ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ), ], ); @@ -294,13 +380,27 @@ void main() { await tester.pumpAndSettle(); // Assert - expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenCreateButton), + findsOneWidget, + ); }); - testWidgets('should display image upload widget', (WidgetTester tester) async { + testWidgets('should display image upload widget', ( + WidgetTester tester, + ) async { // Arrange Widget imageWidget = GestureDetector( key: AppKeys.imageWidget, @@ -373,68 +473,101 @@ void main() { expect(imageUploadTapped, isTrue); }); - testWidgets('should display blog post creation form with image and video options', (WidgetTester tester) async { - // Arrange - Widget completeForm = Column( - children: [ - const Text('Заголовок'), - const SizedBox(height: 8), - TextFormField( - key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(hintText: 'Введите заголовок поста'), - ), - const SizedBox(height: 16), - const Text('Описание'), - const SizedBox(height: 8), - TextFormField(key: AppKeys.blogPostCreateScreenContentTextField, decoration: const InputDecoration(), maxLines: 6, minLines: 4), - const SizedBox(height: 16), - const Text('Изображение поста'), - const SizedBox(height: 8), - GestureDetector( - key: AppKeys.imageWidget, - onTap: () {}, - child: Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), + testWidgets( + 'should display blog post creation form with image and video options', + (WidgetTester tester) async { + // Arrange + Widget completeForm = Column( + children: [ + const Text('Заголовок'), + const SizedBox(height: 8), + TextFormField( + key: AppKeys.blogPostCreateScreenTitleTextField, + decoration: const InputDecoration( + hintText: 'Введите заголовок поста', ), - child: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), - SizedBox(height: 8), - Text('Добавить изображение'), - ], + ), + const SizedBox(height: 16), + const Text('Описание'), + const SizedBox(height: 8), + TextFormField( + key: AppKeys.blogPostCreateScreenContentTextField, + decoration: const InputDecoration(), + maxLines: 6, + minLines: 4, + ), + const SizedBox(height: 16), + const Text('Изображение поста'), + const SizedBox(height: 8), + GestureDetector( + key: AppKeys.imageWidget, + onTap: () {}, + child: Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_photo_alternate, + size: 48, + color: Colors.grey, + ), + SizedBox(height: 8), + Text('Добавить изображение'), + ], + ), ), ), ), - ), - const SizedBox(height: 16), - const Text('Ссылка на видео'), - const SizedBox(height: 8), - TextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration()), - const SizedBox(height: 32), - ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), - ], - ); + const SizedBox(height: 16), + const Text('Ссылка на видео'), + const SizedBox(height: 8), + TextFormField( + key: AppKeys.blogPostCreateScreenVideoLinkTextField, + decoration: const InputDecoration(), + ), + const SizedBox(height: 32), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ), + ], + ); - // Act - await tester.pumpWidget(createTestWidget(completeForm)); - await tester.pumpAndSettle(); + // Act + await tester.pumpWidget(createTestWidget(completeForm)); + await tester.pumpAndSettle(); - // Assert - expect(find.text('Заголовок'), findsOneWidget); - expect(find.text('Описание'), findsOneWidget); - expect(find.text('Изображение поста'), findsOneWidget); - expect(find.text('Ссылка на видео'), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget); - expect(find.byKey(AppKeys.imageWidget), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget); - }); + // Assert + expect(find.text('Заголовок'), findsOneWidget); + expect(find.text('Описание'), findsOneWidget); + expect(find.text('Изображение поста'), findsOneWidget); + expect(find.text('Ссылка на видео'), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + findsOneWidget, + ); + expect(find.byKey(AppKeys.imageWidget), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenCreateButton), + findsOneWidget, + ); + }, + ); }); } diff --git a/test/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit_test.dart b/test/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit_test.dart index 63699ebc..ef66ebd7 100644 --- a/test/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit_test.dart +++ b/test/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit_test.dart @@ -1,6 +1,8 @@ import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart'; import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart'; +import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -14,6 +16,8 @@ void main() { setUp(() { mockRepository = MockBlogPostRepository(); cubit = BlogPostCreateCubit(blogPostRepository: mockRepository); + registerFallbackValue([]); + registerFallbackValue([]); }); tearDown(() { @@ -21,63 +25,246 @@ void main() { }); test('начальное состояние - BlogPostCreateState', () { - expect(cubit.state, isA()); + expect(cubit.state, const BlogPostCreateState()); }); - test('createBlogPost - успешное создание', () async { - when(() => mockRepository.createBlogPost(any(), any(), any(), any())) - .thenAnswer((_) async => {}); + test('validateForm - пустая форма заполняет formError', () { + final isValid = cubit.validateForm( + title: '', + description: '', + videoUrl: '', + imageCount: 0, + ); - final result = await cubit.createBlogPost(); + expect(isValid, false); + expect(cubit.state.formError, BlogPostFormValidator.contentRequired); + verifyNever( + () => mockRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ); + }); + + test('validateForm - невалидное видео заполняет videoUrlError', () { + final isValid = cubit.validateForm( + title: 'Valid title', + description: '', + videoUrl: 'https://youtube.com/watch?v=abc', + imageCount: 0, + ); + + expect(isValid, false); + expect( + cubit.state.videoUrlError, + BlogPostFormValidator.videoUrlUnsupportedHost, + ); + expect(cubit.state.formError, isNull); + }); + + test( + 'createBlogPost - не вызывает репозиторий при ошибке валидации', + () async { + final result = await cubit.createBlogPost(); + + expect(result, false); + expect(cubit.state.hasValidationErrors, true); + verifyNever( + () => mockRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ); + }, + ); + + test('createBlogPost - успешное создание', () async { + when( + () => mockRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ).thenAnswer((_) async => {}); + + final result = await cubit.createBlogPost( + title: 'Test Title', + description: 'Test Description', + ); expect(result, true); - expect(cubit.state, isA()); - verify(() => mockRepository.createBlogPost(null, null, null, null)).called(1); + expect(cubit.state.isSuccess, true); + verify( + () => mockRepository.createBlogPost( + title: 'Test Title', + videoUrl: null, + description: 'Test Description', + images: const [], + ), + ).called(1); }); test('createBlogPost - обработка ошибки', () async { - when(() => mockRepository.createBlogPost(any(), any(), any(), any())) - .thenThrow(Exception('Error')); + when( + () => mockRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ).thenThrow(Exception('Error')); - final result = await cubit.createBlogPost(); + final result = await cubit.createBlogPost( + title: 'Test Title', + description: 'Test Description', + ); expect(result, false); - expect(cubit.state, isA()); - final errorState = cubit.state as BlogPostCreateStateError; - expect(errorState.errorMessage, contains('Error')); + expect(cubit.state.errorMessage, contains('Error')); + expect(cubit.state.isSuccess, false); }); + test( + 'updateBlogPost - не вызывает репозиторий при ошибке валидации', + () async { + final result = await cubit.updateBlogPost(id: 1); + + expect(result, false); + expect(cubit.state.hasValidationErrors, true); + expect(cubit.state.formError, BlogPostFormValidator.contentRequired); + verifyNever( + () => mockRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ); + }, + ); + + test('updateBlogPost - невалидное видео не уходит в репозиторий', () async { + final result = await cubit.updateBlogPost( + id: 1, + title: 'Valid title', + videoUrl: 'https://youtube.com/watch?v=abc', + existingMediaIds: const [10], + ); + + expect(result, false); + expect( + cubit.state.videoUrlError, + BlogPostFormValidator.videoUrlUnsupportedHost, + ); + verifyNever( + () => mockRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ); + }); + + test( + 'updateBlogPost - короткий заголовок не уходит в репозиторий', + () async { + final result = await cubit.updateBlogPost( + id: 1, + title: 'ab', + existingMediaIds: const [10], + ); + + expect(result, false); + expect(cubit.state.titleError, BlogPostFormValidator.titleTooShort); + verifyNever( + () => mockRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ); + }, + ); + test('updateBlogPost - успешное обновление', () async { const id = 1; const title = 'Updated Title'; - when(() => mockRepository.updateBlogPost(any(), any(), any(), any(), any(), any())) - .thenAnswer((_) async => {}); + when( + () => mockRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ).thenAnswer((_) async => {}); - final result = await cubit.updateBlogPost(id: id, title: title); + final result = await cubit.updateBlogPost( + id: id, + title: title, + existingMediaIds: [2, 1], + mediaIdsToDelete: [9], + ); expect(result, true); - expect(cubit.state, isA()); - verify(() => mockRepository.updateBlogPost(id, title, null, null, null, null)).called(1); + expect(cubit.state.isSuccess, true); + verify( + () => mockRepository.updateBlogPost( + id: id, + title: title, + videoUrl: '', + description: '', + existingMediaIds: [2, 1], + newImages: const [], + mediaIdsToDelete: [9], + ), + ).called(1); }); test('updateBlogPost - обработка ошибки', () async { const id = 1; - when(() => mockRepository.updateBlogPost(any(), any(), any(), any(), any(), any())) - .thenThrow(Exception('Error')); + when( + () => mockRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ).thenThrow(Exception('Error')); - final result = await cubit.updateBlogPost(id: id); + final result = await cubit.updateBlogPost(id: id, title: 'Valid title'); expect(result, false); - expect(cubit.state, isA()); + expect(cubit.state.errorMessage, contains('Error')); }); test('deleteBlogPost - успешное удаление', () async { const id = 1; - when(() => mockRepository.deleteBlogPost(id)) - .thenAnswer((_) async => {}); + when(() => mockRepository.deleteBlogPost(id)).thenAnswer((_) async => {}); final result = await cubit.deleteBlogPost(id); @@ -88,13 +275,27 @@ void main() { test('deleteBlogPost - обработка ошибки', () async { const id = 1; - when(() => mockRepository.deleteBlogPost(id)) - .thenThrow(Exception('Error')); + when( + () => mockRepository.deleteBlogPost(id), + ).thenThrow(Exception('Error')); final result = await cubit.deleteBlogPost(id); expect(result, false); }); + + test('clearTitleError очищает titleError и formError', () { + cubit.validateForm( + title: '', + description: '', + videoUrl: '', + imageCount: 0, + ); + expect(cubit.state.formError, isNotNull); + + cubit.clearTitleError(); + + expect(cubit.state.formError, isNull); + }); }); } - diff --git a/test/features/blog_post_create/integration_widget_test.dart b/test/features/blog_post_create/integration_widget_test.dart index d4fd63f8..1eaad104 100644 --- a/test/features/blog_post_create/integration_widget_test.dart +++ b/test/features/blog_post_create/integration_widget_test.dart @@ -21,10 +21,16 @@ void main() { setUp(() { blogPostRepository = FixtureBlogPostRepository(); - blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository); + blogPostCreateCubit = BlogPostCreateCubit( + blogPostRepository: blogPostRepository, + ); feedProbe = FeedReloadProbe(); - fullCreateFixture = FixtureReader.readAsMap('blog_post_create/create_request_full.json'); - updateFixture = FixtureReader.readAsMap('blog_post_create/update_request.json'); + fullCreateFixture = FixtureReader.readAsMap( + 'blog_post_create/create_request_full.json', + ); + updateFixture = FixtureReader.readAsMap( + 'blog_post_create/update_request.json', + ); }); tearDown(() { @@ -41,11 +47,14 @@ void main() { ); } - testWidgets('should complete full blog post creation flow', (WidgetTester tester) async { + testWidgets('should complete full blog post creation flow', ( + WidgetTester tester, + ) async { // Arrange - Widget fullFlowWidget = BlocListener( + Widget + fullFlowWidget = BlocListener( listener: (context, state) { - if (state is BlogPostCreateStateLoaded) { + if (state.isSuccess) { feedProbe.loadFeedBlogPostList(); } }, @@ -82,7 +91,11 @@ void main() { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), + Icon( + Icons.add_photo_alternate, + size: 48, + color: Colors.grey, + ), SizedBox(height: 8), Text('Добавить изображение'), ], @@ -98,7 +111,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: fullCreateFixture['video_url'] as String?, description: fullCreateFixture['description_md'] as String?, - image: null, ); }, child: const Text('Опубликовать'), @@ -111,27 +123,46 @@ void main() { await tester.pumpWidget(createTestWidget(fullFlowWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + fullCreateFixture['title'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + fullCreateFixture['description_md'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + fullCreateFixture['video_url'] as String, + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.pump(); // Assert - expect(blogPostRepository.createPayloads.single.toJson(), fullCreateFixture); + expect( + blogPostRepository.createPayloads.single.toJson(), + fullCreateFixture, + ); expect(feedProbe.loadRequests, 1); }); - testWidgets('should handle blog post update flow', (WidgetTester tester) async { + testWidgets('should handle blog post update flow', ( + WidgetTester tester, + ) async { // Arrange final existingBlogPost = FeedBlogPostModel( id: 1, state: 'published', image: 'original_image.jpg', description: 'Original Description', - author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true), + author: const Author( + authorSlug: 'test-author', + authorName: 'Test Author', + authorProfileIcon: 'avatar.jpg', + authorIsMe: true, + ), videoUrl: 'https://youtube.com/watch?v=original', createdAt: DateTime.now(), updatedAt: DateTime.now(), @@ -142,9 +173,10 @@ void main() { wasLikedByMe: false, ); - Widget updateFlowWidget = BlocListener( + Widget + updateFlowWidget = BlocListener( listener: (context, state) { - if (state is BlogPostCreateStateLoaded) { + if (state.isSuccess) { feedProbe.loadFeedBlogPostList(); } }, @@ -161,13 +193,17 @@ void main() { decoration: const InputDecoration(labelText: 'Описание'), maxLines: 6, minLines: 4, - controller: TextEditingController(text: existingBlogPost.description), + controller: TextEditingController( + text: existingBlogPost.description, + ), ), const SizedBox(height: 16), TextFormField( key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration(labelText: 'Ссылка на видео'), - controller: TextEditingController(text: existingBlogPost.videoUrl), + controller: TextEditingController( + text: existingBlogPost.videoUrl, + ), ), const SizedBox(height: 16), ElevatedButton( @@ -178,8 +214,9 @@ void main() { title: updateFixture['title'] as String?, videoUrl: updateFixture['video_url'] as String?, description: updateFixture['description_md'] as String?, - image: null, - oldImageUrl: updateFixture['old_image_url'] as String?, + existingMediaIds: List.from( + updateFixture['existing_media_ids'] as List, + ), ); }, child: const Text('Обновить'), @@ -192,9 +229,18 @@ void main() { await tester.pumpWidget(createTestWidget(updateFlowWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), updateFixture['title'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), updateFixture['description_md'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), updateFixture['video_url'] as String); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + updateFixture['title'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + updateFixture['description_md'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + updateFixture['video_url'] as String, + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); @@ -205,7 +251,9 @@ void main() { expect(feedProbe.loadRequests, 1); }); - testWidgets('should handle blog post deletion flow', (WidgetTester tester) async { + testWidgets('should handle blog post deletion flow', ( + WidgetTester tester, + ) async { // Arrange Widget deleteFlowWidget = Column( children: [ @@ -230,35 +278,44 @@ void main() { await tester.pump(); // Assert - final deleteFixture = FixtureReader.readAsMap('blog_post_create/delete_request.json'); + final deleteFixture = FixtureReader.readAsMap( + 'blog_post_create/delete_request.json', + ); expect(blogPostRepository.deleteCalls.single, deleteFixture['id']); expect(feedProbe.loadRequests, 1); }); - testWidgets('should handle error flow during blog post creation', (WidgetTester tester) async { + testWidgets('should handle error flow during blog post creation', ( + WidgetTester tester, + ) async { // Arrange blogPostRepository.createError = Exception('Network error'); - Widget errorFlowWidget = BlocBuilder( - builder: (context, state) { - if (state is BlogPostCreateStateError) { - return Column( - children: [ - Text('Error: ${state.errorMessage}'), - const SizedBox(height: 16), - ElevatedButton( - key: const Key('retryButton'), - onPressed: () async { - await blogPostCreateCubit.createBlogPost(title: 'Retry Title', videoUrl: null, description: 'Retry Description', image: null); - }, - child: const Text('Повторить'), - ), - ], - ); - } - return const Text('No Error'); - }, - ); + Widget errorFlowWidget = + BlocBuilder( + builder: (context, state) { + if (state.errorMessage != null) { + return Column( + children: [ + Text('Error: ${state.errorMessage}'), + const SizedBox(height: 16), + ElevatedButton( + key: const Key('retryButton'), + onPressed: () async { + await blogPostCreateCubit.createBlogPost( + title: 'Retry Title', + videoUrl: null, + description: 'Retry Description', + ); + }, + child: const Text('Повторить'), + ), + ], + ); + } + return const Text('No Error'); + }, + ); // Act await tester.pumpWidget(createTestWidget(errorFlowWidget)); @@ -269,7 +326,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: null, description: fullCreateFixture['description_md'] as String?, - image: null, ); await tester.pump(); @@ -278,18 +334,27 @@ void main() { expect(find.byKey(const Key('retryButton')), findsOneWidget); }); - testWidgets('should handle loading state during blog post creation', (WidgetTester tester) async { + testWidgets('should handle loading state during blog post creation', ( + WidgetTester tester, + ) async { // Arrange blogPostRepository.createDelay = const Duration(milliseconds: 50); - Widget loadingFlowWidget = BlocBuilder( - builder: (context, state) { - if (state is BlogPostCreateStateLoading) { - return const Column(children: [CircularProgressIndicator(), SizedBox(height: 16), Text('Создание блог поста...')]); - } - return const Text('Not Loading'); - }, - ); + Widget loadingFlowWidget = + BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Создание блог поста...'), + ], + ); + } + return const Text('Not Loading'); + }, + ); // Act await tester.pumpWidget(createTestWidget(loadingFlowWidget)); @@ -300,7 +365,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: null, description: fullCreateFixture['description_md'] as String?, - image: null, ); await tester.pump(); await tester.pump(); // Need to pump twice to get the loading state @@ -314,29 +378,32 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('should handle success state after blog post creation', (WidgetTester tester) async { + testWidgets('should handle success state after blog post creation', ( + WidgetTester tester, + ) async { // Arrange - Widget successFlowWidget = BlocListener( - listener: (context, state) { - if (state is BlogPostCreateStateLoaded) { - feedProbe.loadFeedBlogPostList(); - } - }, - child: BlocBuilder( - builder: (context, state) { - if (state is BlogPostCreateStateLoaded) { - return const Column( - children: [ - Icon(Icons.check_circle, color: Colors.green, size: 48), - SizedBox(height: 16), - Text('Блог пост успешно создан!'), - ], - ); - } - return const Text('Not Loaded'); - }, - ), - ); + Widget successFlowWidget = + BlocListener( + listener: (context, state) { + if (state.isSuccess) { + feedProbe.loadFeedBlogPostList(); + } + }, + child: BlocBuilder( + builder: (context, state) { + if (state.isSuccess) { + return const Column( + children: [ + Icon(Icons.check_circle, color: Colors.green, size: 48), + SizedBox(height: 16), + Text('Блог пост успешно создан!'), + ], + ); + } + return const Text('Not Loaded'); + }, + ), + ); // Act await tester.pumpWidget(createTestWidget(successFlowWidget)); @@ -347,7 +414,6 @@ void main() { title: fullCreateFixture['title'] as String?, videoUrl: null, description: fullCreateFixture['description_md'] as String?, - image: null, ); await tester.pump(); @@ -357,28 +423,43 @@ void main() { expect(feedProbe.loadRequests, 1); }); - testWidgets('should handle form validation flow', (WidgetTester tester) async { + testWidgets('should handle form validation flow', ( + WidgetTester tester, + ) async { // Arrange Widget validationFlowWidget = Column( children: [ TextFormField( key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок обязателен'), + decoration: const InputDecoration( + labelText: 'Заголовок', + errorText: 'Заголовок обязателен', + ), ), const SizedBox(height: 16), TextFormField( key: AppKeys.blogPostCreateScreenContentTextField, - decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание обязательно'), + decoration: const InputDecoration( + labelText: 'Описание', + errorText: 'Описание обязательно', + ), maxLines: 6, minLines: 4, ), const SizedBox(height: 16), TextFormField( key: AppKeys.blogPostCreateScreenVideoLinkTextField, - decoration: const InputDecoration(labelText: 'Ссылка на видео', errorText: 'Введите корректную ссылку'), + decoration: const InputDecoration( + labelText: 'Ссылка на видео', + errorText: 'Введите корректную ссылку', + ), ), const SizedBox(height: 16), - ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () {}, + child: const Text('Опубликовать'), + ), ], ); @@ -415,7 +496,11 @@ void main() { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), + Icon( + Icons.add_photo_alternate, + size: 48, + color: Colors.grey, + ), SizedBox(height: 8), Text('Добавить изображение'), ], @@ -431,7 +516,6 @@ void main() { title: 'Test Title', videoUrl: null, description: 'Test Description', - image: null, // В реальном тесте здесь был бы XFile ); }, child: const Text('Опубликовать'), @@ -450,90 +534,122 @@ void main() { expect(imageUploadInitiated, isTrue); }); - testWidgets('should handle complete blog post creation with all features', (WidgetTester tester) async { + testWidgets('should handle complete blog post creation with all features', ( + WidgetTester tester, + ) async { // Arrange - Widget completeFlowWidget = BlocListener( - listener: (context, state) { - if (state is BlogPostCreateStateLoaded) { - feedProbe.loadFeedBlogPostList(); - } - }, - child: Column( - children: [ - const Text('Заголовок'), - const SizedBox(height: 8), - TextFormField( - key: AppKeys.blogPostCreateScreenTitleTextField, - decoration: const InputDecoration(hintText: 'Введите заголовок поста'), - ), - const SizedBox(height: 16), - const Text('Описание'), - const SizedBox(height: 8), - TextFormField(key: AppKeys.blogPostCreateScreenContentTextField, decoration: const InputDecoration(), maxLines: 6, minLines: 4), - const SizedBox(height: 16), - const Text('Изображение поста'), - const SizedBox(height: 8), - GestureDetector( - key: AppKeys.imageWidget, - onTap: () {}, - child: Container( - height: 200, - width: double.infinity, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), - ), - child: const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey), - SizedBox(height: 8), - Text('Добавить изображение'), - ], + Widget completeFlowWidget = + BlocListener( + listener: (context, state) { + if (state.isSuccess) { + feedProbe.loadFeedBlogPostList(); + } + }, + child: Column( + children: [ + const Text('Заголовок'), + const SizedBox(height: 8), + TextFormField( + key: AppKeys.blogPostCreateScreenTitleTextField, + decoration: const InputDecoration( + hintText: 'Введите заголовок поста', ), ), - ), + const SizedBox(height: 16), + const Text('Описание'), + const SizedBox(height: 8), + TextFormField( + key: AppKeys.blogPostCreateScreenContentTextField, + decoration: const InputDecoration(), + maxLines: 6, + minLines: 4, + ), + const SizedBox(height: 16), + const Text('Изображение поста'), + const SizedBox(height: 8), + GestureDetector( + key: AppKeys.imageWidget, + onTap: () {}, + child: Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_photo_alternate, + size: 48, + color: Colors.grey, + ), + SizedBox(height: 8), + Text('Добавить изображение'), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + const Text('Ссылка на видео'), + const SizedBox(height: 8), + TextFormField( + key: AppKeys.blogPostCreateScreenVideoLinkTextField, + decoration: const InputDecoration(), + ), + const SizedBox(height: 32), + ElevatedButton( + key: AppKeys.blogPostCreateScreenCreateButton, + onPressed: () async { + await blogPostCreateCubit.createBlogPost( + title: fullCreateFixture['title'] as String?, + videoUrl: fullCreateFixture['video_url'] as String?, + description: + fullCreateFixture['description_md'] as String?, + ); + }, + child: const Text('Опубликовать'), + ), + ], ), - const SizedBox(height: 16), - const Text('Ссылка на видео'), - const SizedBox(height: 8), - TextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration()), - const SizedBox(height: 32), - ElevatedButton( - key: AppKeys.blogPostCreateScreenCreateButton, - onPressed: () async { - await blogPostCreateCubit.createBlogPost( - title: fullCreateFixture['title'] as String?, - videoUrl: fullCreateFixture['video_url'] as String?, - description: fullCreateFixture['description_md'] as String?, - image: null, - ); - }, - child: const Text('Опубликовать'), - ), - ], - ), - ); + ); // Act await tester.pumpWidget(createTestWidget(completeFlowWidget)); await tester.pumpAndSettle(); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + fullCreateFixture['title'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + fullCreateFixture['description_md'] as String, + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + fullCreateFixture['video_url'] as String, + ); await tester.pump(); // Scroll to make sure the button is visible - await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -200)); + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -200), + ); await tester.pump(); await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton)); await tester.pump(); // Assert - expect(blogPostRepository.createPayloads.single.toJson(), fullCreateFixture); + expect( + blogPostRepository.createPayloads.single.toJson(), + fullCreateFixture, + ); expect(feedProbe.loadRequests, 1); }); }); diff --git a/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart b/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart index 13b86880..f429bf80 100644 --- a/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart +++ b/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/features/blog_post_create/presentation/pages/blog_pos import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart'; +import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/generated/l10n.dart'; @@ -19,14 +20,15 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:network_image_mock/network_image_mock.dart'; -import 'blog_post_create_screen_test.mocks.dart'; +class MockBlogPostRepository extends Mock implements BlogPostRepository {} + +class MockFeedCubit extends Mock implements FeedCubit {} -@GenerateMocks([BlogPostRepository, BlogPostCreateCubit, FeedCubit]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -37,13 +39,22 @@ void main() { setUp(() async { await sl.reset(); - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); AppConfigRegistration.registerAppConfig(buildType: AppBuildType.tests); + registerFallbackValue([]); + registerFallbackValue([]); mockBlogPostRepository = MockBlogPostRepository(); - blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: mockBlogPostRepository); + blogPostCreateCubit = BlogPostCreateCubit( + blogPostRepository: mockBlogPostRepository, + ); mockFeedCubit = MockFeedCubit(); - when(mockFeedCubit.state).thenReturn(const FeedState.loading()); - when(mockFeedCubit.stream).thenAnswer((_) => Stream.value(const FeedState.loading())); + when(() => mockFeedCubit.state).thenReturn(const FeedState.loading()); + when( + () => mockFeedCubit.stream, + ).thenAnswer((_) => Stream.value(const FeedState.loading())); + when(() => mockFeedCubit.loadFeedBlogPostList()).thenAnswer((_) async {}); sl.registerLazySingleton(() => mockFeedCubit); sl.registerLazySingleton(() => blogPostCreateCubit); }); @@ -52,19 +63,25 @@ void main() { blogPostCreateCubit.close(); }); - Widget createTestWidget({FeedBlogPostModel? feedBlogPostModel, bool canPop = false}) { + Widget createTestWidget({ + FeedBlogPostModel? feedBlogPostModel, + bool canPop = false, + }) { final router = GoRouter( initialLocation: '/blog-post-create', routes: [ GoRoute( path: '/home', - builder: (context, state) => const Scaffold(body: Center(child: Text('Home'))), + builder: (context, state) => + const Scaffold(body: Center(child: Text('Home'))), ), GoRoute( path: '/blog-post-create', builder: (context, state) => MultiBlocProvider( providers: [ - BlocProvider.value(value: blogPostCreateCubit), + BlocProvider.value( + value: blogPostCreateCubit, + ), BlocProvider.value(value: mockFeedCubit), ], child: BlogPostCreateScreen(feedBlogPostModel: feedBlogPostModel), @@ -87,28 +104,46 @@ void main() { ); } - testWidgets('displays all form fields when creating new blog post', (tester) async { + testWidgets('displays all form fields when creating new blog post', ( + tester, + ) async { await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); expect(find.text('Заголовок'), findsOneWidget); expect(find.text('Описание'), findsOneWidget); - expect(find.text('Изображение поста'), findsOneWidget); + expect(find.textContaining('Изображения'), findsOneWidget); expect(find.text('Ссылка на видео'), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget); - expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + findsOneWidget, + ); + expect( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + findsOneWidget, + ); }); - testWidgets('pre-fills form fields when editing existing blog post', (tester) async { + testWidgets('pre-fills form fields when editing existing blog post', ( + tester, + ) async { final existingPost = FeedBlogPostModel( id: 1, title: 'Test Title', description: 'Test Description', - videoUrl: 'https://youtube.com/watch?v=test', - image: '', // Changed: removed image so video field is visible + videoUrl: 'https://rutube.ru/video/12345/', + image: '', state: 'published', - author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true), + author: const Author( + authorSlug: 'test-author', + authorName: 'Test Author', + authorProfileIcon: 'avatar.jpg', + authorIsMe: true, + ), createdAt: DateTime.now(), updatedAt: DateTime.now(), totalLikes: 0, @@ -117,24 +152,299 @@ void main() { wasLikedByMe: false, ); - await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost)); + await tester.pumpWidget( + createTestWidget(feedBlogPostModel: existingPost), + ); await tester.pumpAndSettle(); - final titleField = tester.widget(find.byKey(AppKeys.blogPostCreateScreenTitleTextField)); + final titleField = tester.widget( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + ); expect(titleField.controller?.text, 'Test Title'); - final descriptionField = tester.widget(find.byKey(AppKeys.blogPostCreateScreenContentTextField)); + final descriptionField = tester.widget( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + ); expect(descriptionField.controller?.text, 'Test Description'); - final videoFieldFinder = find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField); - if (videoFieldFinder.evaluate().isNotEmpty) { - final videoField = tester.widget(videoFieldFinder); - expect(videoField.controller?.text, 'https://youtube.com/watch?v=test'); - } + final videoField = tester.widget( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + ); + expect(videoField.controller?.text, 'https://rutube.ru/video/12345/'); + expect(find.text('Сохранить'), findsOneWidget); }); - testWidgets('hides video field when image is present', (tester) async { + testWidgets('shows video and gallery fields together', (tester) async { await mockNetworkImagesFor(() async { + final existingPost = FeedBlogPostModel( + id: 1, + title: 'Test Title', + description: 'Test Description', + videoUrl: 'https://rutube.ru/video/12345/', + image: 'http://localhost/storage/test_image.jpg', + state: 'published', + author: const Author( + authorSlug: 'test-author', + authorName: 'Test Author', + authorProfileIcon: 'http://localhost/storage/avatar.jpg', + authorIsMe: true, + ), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + totalLikes: 0, + totalViews: 0, + totalComments: 0, + wasLikedByMe: false, + images: const [ + FeedGalleryImage( + id: 10, + url: 'http://localhost/storage/test_image.jpg', + ), + ], + ); + + await tester.pumpWidget( + createTestWidget(feedBlogPostModel: existingPost), + ); + await tester.pumpAndSettle(); + + expect(find.text('Ссылка на видео'), findsOneWidget); + expect( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + findsOneWidget, + ); + expect(find.textContaining('Изображения'), findsOneWidget); + }); + }); + + testWidgets('allows entering text in title field', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + 'New Title', + ); + await tester.pump(); + + final titleField = tester.widget( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + ); + expect(titleField.controller?.text, 'New Title'); + }); + + testWidgets('allows entering text in description field', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + 'New Description', + ); + await tester.pump(); + + final descriptionField = tester.widget( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + ); + expect(descriptionField.controller?.text, 'New Description'); + }); + + testWidgets('allows entering text in video URL field', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + 'https://youtube.com/watch?v=new', + ); + await tester.pump(); + + final videoField = tester.widget( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + ); + expect(videoField.controller?.text, 'https://youtube.com/watch?v=new'); + }); + + testWidgets('displays create button', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Создать'), findsOneWidget); + }); + + testWidgets('does not submit when form is empty', (tester) async { + when( + () => mockBlogPostRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ).thenAnswer((_) async {}); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final createButton = find.text('Создать'); + await tester.ensureVisible(createButton); + await tester.tap(createButton); + await tester.pumpAndSettle(); + + verifyNever( + () => mockBlogPostRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ); + expect( + find.text( + 'Добавьте заголовок, описание, изображение или ссылку на видео', + ), + findsOneWidget, + ); + }); + + testWidgets('does not submit when video url is invalid', (tester) async { + when( + () => mockBlogPostRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ).thenAnswer((_) async {}); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + 'Test Title', + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + 'https://youtube.com/watch?v=abc', + ); + await tester.pump(); + + final createButton = find.text('Создать'); + await tester.ensureVisible(createButton); + await tester.tap(createButton); + await tester.pumpAndSettle(); + + verifyNever( + () => mockBlogPostRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ); + expect( + find.text('Поддерживаются только Rutube, VK и OK'), + findsOneWidget, + ); + }); + + testWidgets('shows loading state when creating blog post', (tester) async { + when( + () => mockBlogPostRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + return; + }); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + 'Test Title', + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + 'Test Description', + ); + await tester.pump(); + + final createButton = find.text('Создать'); + await tester.ensureVisible(createButton); + await tester.tap(createButton); + await tester.pump(); + + expect(find.byType(Loader), findsWidgets); + + await tester.pump(const Duration(milliseconds: 150)); + await tester.pumpAndSettle(); + }); + + testWidgets( + 'calls FeedCubit.loadFeedBlogPostList when blog post is created successfully', + (tester) async { + when( + () => mockBlogPostRepository.createBlogPost( + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + images: any(named: 'images'), + ), + ).thenAnswer((_) async { + return; + }); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final titleField = find.byKey( + AppKeys.blogPostCreateScreenTitleTextField, + ); + final contentField = find.byKey( + AppKeys.blogPostCreateScreenContentTextField, + ); + + await tester.ensureVisible(titleField); + await tester.ensureVisible(contentField); + await tester.pumpAndSettle(); + + await tester.enterText(titleField, 'Test Title'); + await tester.enterText(contentField, 'Test Description'); + await tester.pump(); + + final createButton = find.text('Создать'); + await tester.ensureVisible(createButton); + await tester.tap(createButton, warnIfMissed: false); + await tester.pump(); + + await tester.pump(const Duration(milliseconds: 200)); + await tester.pumpAndSettle(); + + verify(() => mockFeedCubit.loadFeedBlogPostList()).called(1); + }, + ); + + testWidgets('does not submit update when video url is invalid', ( + tester, + ) async { + await mockNetworkImagesFor(() async { + when( + () => mockBlogPostRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ).thenAnswer((_) async {}); + final existingPost = FeedBlogPostModel( id: 1, title: 'Test Title', @@ -154,136 +464,140 @@ void main() { totalViews: 0, totalComments: 0, wasLikedByMe: false, + images: const [ + FeedGalleryImage( + id: 10, + url: 'http://localhost/storage/test_image.jpg', + ), + ], ); - await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost)); + await tester.pumpWidget( + createTestWidget(feedBlogPostModel: existingPost), + ); await tester.pumpAndSettle(); - expect(find.text('Ссылка на видео'), findsNothing); - expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsNothing); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), + 'https://youtube.com/watch?v=abc', + ); + await tester.pump(); + + final saveButton = find.text('Сохранить'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + + verifyNever( + () => mockBlogPostRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ); + expect( + find.text('Поддерживаются только Rutube, VK и OK'), + findsOneWidget, + ); }); }); - testWidgets('hides image field when video URL is present', (tester) async { - final existingPost = FeedBlogPostModel( - id: 1, - title: 'Test Title', - description: 'Test Description', - videoUrl: 'https://youtube.com/watch?v=test', - image: '', - state: 'published', - author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true), - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - totalLikes: 0, - totalViews: 0, - totalComments: 0, - wasLikedByMe: false, - ); - - await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost)); - await tester.pumpAndSettle(); - - expect(find.text('Изображение поста'), findsNothing); - }); - - testWidgets('allows entering text in title field', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'New Title'); - await tester.pump(); - - final titleField = tester.widget(find.byKey(AppKeys.blogPostCreateScreenTitleTextField)); - expect(titleField.controller?.text, 'New Title'); - }); - - testWidgets('allows entering text in description field', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), 'New Description'); - await tester.pump(); - - final descriptionField = tester.widget(find.byKey(AppKeys.blogPostCreateScreenContentTextField)); - expect(descriptionField.controller?.text, 'New Description'); - }); - - testWidgets('allows entering text in video URL field', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), 'https://youtube.com/watch?v=new'); - await tester.pump(); - - final videoField = tester.widget(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField)); - expect(videoField.controller?.text, 'https://youtube.com/watch?v=new'); - }); - - testWidgets('displays create button', (tester) async { - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - expect(find.text('Создать'), findsOneWidget); - }); - - testWidgets('shows loading state when creating blog post', (tester) async { - when(mockBlogPostRepository.createBlogPost(any, any, any, any)).thenAnswer((_) async { - await Future.delayed(const Duration(milliseconds: 100)); - return; - }); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'Test Title'); - await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), 'Test Description'); - await tester.pump(); - - final createButton = find.text('Создать'); - await tester.ensureVisible(createButton); - await tester.tap(createButton); - await tester.pump(); - - expect(find.byType(Loader), findsWidgets); - - await tester.pump(const Duration(milliseconds: 150)); - await tester.pumpAndSettle(); - }); - - testWidgets('calls FeedCubit.loadFeedBlogPostList when blog post is created successfully', (tester) async { - when(mockBlogPostRepository.createBlogPost(any, any, any, any)).thenAnswer((_) async { - return; - }); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - final titleField = find.byKey(AppKeys.blogPostCreateScreenTitleTextField); - final contentField = find.byKey(AppKeys.blogPostCreateScreenContentTextField); - - await tester.ensureVisible(titleField); - await tester.ensureVisible(contentField); - await tester.pumpAndSettle(); - - await tester.enterText(titleField, 'Test Title'); - await tester.enterText(contentField, 'Test Description'); - await tester.pump(); - - final createButton = find.text('Создать'); - await tester.ensureVisible(createButton); - await tester.tap(createButton, warnIfMissed: false); - await tester.pump(); - - await tester.pump(const Duration(milliseconds: 200)); - await tester.pumpAndSettle(); - - verify(mockFeedCubit.loadFeedBlogPostList()).called(1); - }); - - testWidgets('calls updateBlogPost when editing existing post', (tester) async { + testWidgets('does not submit update when form content is cleared', ( + tester, + ) async { await mockNetworkImagesFor(() async { - when(mockBlogPostRepository.updateBlogPost(any, any, any, any, any, any)).thenAnswer((_) async { + when( + () => mockBlogPostRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ).thenAnswer((_) async {}); + + final existingPost = FeedBlogPostModel( + id: 1, + title: 'Test Title', + description: 'Test Description', + videoUrl: '', + image: '', + state: 'published', + author: const Author( + authorSlug: 'test-author', + authorName: 'Test Author', + authorProfileIcon: 'http://localhost/storage/avatar.jpg', + authorIsMe: true, + ), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + totalLikes: 0, + totalViews: 0, + totalComments: 0, + wasLikedByMe: false, + ); + + await tester.pumpWidget( + createTestWidget(feedBlogPostModel: existingPost), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenTitleTextField), + '', + ); + await tester.enterText( + find.byKey(AppKeys.blogPostCreateScreenContentTextField), + '', + ); + await tester.pump(); + + final saveButton = find.text('Сохранить'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton); + await tester.pumpAndSettle(); + + verifyNever( + () => mockBlogPostRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ); + expect( + find.text( + 'Добавьте заголовок, описание, изображение или ссылку на видео', + ), + findsOneWidget, + ); + }); + }); + + testWidgets('calls updateBlogPost when editing existing post', ( + tester, + ) async { + await mockNetworkImagesFor(() async { + when( + () => mockBlogPostRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ).thenAnswer((_) async { return; }); @@ -306,26 +620,46 @@ void main() { totalViews: 0, totalComments: 0, wasLikedByMe: false, + images: const [ + FeedGalleryImage( + id: 10, + url: 'http://localhost/storage/test_image.jpg', + ), + ], ); - await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost, canPop: true)); + await tester.pumpWidget( + createTestWidget(feedBlogPostModel: existingPost, canPop: true), + ); await tester.pumpAndSettle(); - final titleField = find.byKey(AppKeys.blogPostCreateScreenTitleTextField); + final titleField = find.byKey( + AppKeys.blogPostCreateScreenTitleTextField, + ); expect(titleField, findsOneWidget); await tester.enterText(titleField, 'Updated Title'); await tester.pump(); - final createButton = find.text('Создать'); - await tester.ensureVisible(createButton); - await tester.tap(createButton, warnIfMissed: false); + final saveButton = find.text('Сохранить'); + await tester.ensureVisible(saveButton); + await tester.tap(saveButton, warnIfMissed: false); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); await tester.pumpAndSettle(); - verify(mockBlogPostRepository.updateBlogPost(any, any, any, any, any, any)).called(1); + verify( + () => mockBlogPostRepository.updateBlogPost( + id: any(named: 'id'), + title: any(named: 'title'), + videoUrl: any(named: 'videoUrl'), + description: any(named: 'description'), + existingMediaIds: any(named: 'existingMediaIds'), + newImages: any(named: 'newImages'), + mediaIdsToDelete: any(named: 'mediaIdsToDelete'), + ), + ).called(1); }); }); @@ -344,8 +678,9 @@ void main() { }); testWidgets('renders error state', (tester) async { - - blogPostCreateCubit.emit(const BlogPostCreateState.error(errorMessage: 'Error creating post')); + blogPostCreateCubit.emit( + const BlogPostCreateState(errorMessage: 'Error creating post'), + ); await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); @@ -354,7 +689,7 @@ void main() { }); testWidgets('renders loading state', (tester) async { - blogPostCreateCubit.emit(const BlogPostCreateState.loading()); + blogPostCreateCubit.emit(const BlogPostCreateState(isLoading: true)); await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); diff --git a/test/features/blog_post_create/repositories/blog_post_repository_test.dart b/test/features/blog_post_create/repositories/blog_post_repository_test.dart index 1c6ef9e1..252d6fed 100644 --- a/test/features/blog_post_create/repositories/blog_post_repository_test.dart +++ b/test/features/blog_post_create/repositories/blog_post_repository_test.dart @@ -25,204 +25,172 @@ void main() { test('deleteBlogPost вызывает сервис', () async { const id = 1; - when(() => mockService.deleteBlogPost(id: id)).thenAnswer((_) async => {}); + when( + () => mockService.deleteBlogPost(id: id), + ).thenAnswer((_) async => {}); await repository.deleteBlogPost(id); verify(() => mockService.deleteBlogPost(id: id)).called(1); }); + test('deleteBlogPostMedia вызывает сервис', () async { + when( + () => mockService.deleteBlogPostMedia(postId: 10, mediaId: 20), + ).thenAnswer((_) async => {}); + + await repository.deleteBlogPostMedia(postId: 10, mediaId: 20); + + verify( + () => mockService.deleteBlogPostMedia(postId: 10, mediaId: 20), + ).called(1); + }); + test('createBlogPost вызывает сервис с FormData', () async { - const title = 'Test Title'; - const description = 'Test Description'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(title, null, description, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('updateBlogPost вызывает сервис с FormData', () async { - const id = 1; - const title = 'Updated Title'; - when( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), + () => mockService.createBlogPost(formData: any(named: 'formData')), ).thenAnswer((_) async => {}); - await repository.updateBlogPost(id, title, null, null, null, null); + await repository.createBlogPost( + title: 'Test Title', + description: 'Test Description', + ); - verify( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), - ).called(1); - }); + final captured = + verify( + () => mockService.createBlogPost( + formData: captureAny(named: 'formData'), + ), + ).captured.single + as FormData; - test('createBlogPost - обрабатывает пустые поля', () async { - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(null, null, null, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('createBlogPost - обрабатывает только title', () async { - const title = 'Test Title'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(title, null, null, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); + expect( + captured.fields.map((e) => '${e.key}=${e.value}'), + containsAll(['title=Test Title', 'description_md=Test Description']), + ); + expect(captured.files, isEmpty); }); test('createBlogPost - обрабатывает только videoUrl', () async { - const videoUrl = 'https://example.com/video.mp4'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(null, videoUrl, null, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('createBlogPost - обрабатывает только description', () async { - const description = 'Test Description'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(null, null, description, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('updateBlogPost - обрабатывает все поля', () async { - const id = 1; - const title = 'Updated Title'; - const videoUrl = 'https://example.com/video.mp4'; - const description = 'Updated Description'; + const videoUrl = 'https://rutube.ru/video/12345/'; when( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), + () => mockService.createBlogPost(formData: any(named: 'formData')), ).thenAnswer((_) async => {}); - await repository.updateBlogPost(id, title, videoUrl, description, null, null); + await repository.createBlogPost(videoUrl: videoUrl); - verify( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), - ).called(1); + final captured = + verify( + () => mockService.createBlogPost( + formData: captureAny(named: 'formData'), + ), + ).captured.single + as FormData; + + expect( + captured.fields.map((e) => '${e.key}=${e.value}'), + contains('video_url=$videoUrl'), + ); }); + test( + 'updateBlogPost отправляет existing_media_ids и удаляет media пачкой', + () async { + when( + () => mockService.deleteBlogPostMedia( + postId: 1, + mediaId: any(named: 'mediaId'), + ), + ).thenAnswer((_) async => {}); + when( + () => mockService.updateBlogPost( + id: 1, + formData: any(named: 'formData'), + ), + ).thenAnswer((_) async => {}); + + await repository.updateBlogPost( + id: 1, + title: 'Updated Title', + existingMediaIds: [5, 3], + mediaIdsToDelete: [9, 8], + ); + + verify( + () => mockService.deleteBlogPostMedia(postId: 1, mediaId: 9), + ).called(1); + verify( + () => mockService.deleteBlogPostMedia(postId: 1, mediaId: 8), + ).called(1); + + final captured = + verify( + () => mockService.updateBlogPost( + id: 1, + formData: captureAny(named: 'formData'), + ), + ).captured.single + as FormData; + + final fields = captured.fields + .map((e) => '${e.key}=${e.value}') + .toList(); + expect(fields, contains('title=Updated Title')); + expect(fields, contains('description_md=')); + expect(fields, contains('video_url=')); + expect(fields, contains('existing_media_ids[]=5')); + expect(fields, contains('existing_media_ids[]=3')); + }, + ); + + test( + 'updateBlogPost отправляет пустые text fields для очистки на бэке', + () async { + when( + () => mockService.updateBlogPost( + id: 1, + formData: any(named: 'formData'), + ), + ).thenAnswer((_) async => {}); + + await repository.updateBlogPost( + id: 1, + title: '', + description: '', + videoUrl: '', + existingMediaIds: const [], + ); + + final captured = + verify( + () => mockService.updateBlogPost( + id: 1, + formData: captureAny(named: 'formData'), + ), + ).captured.single + as FormData; + + final fields = captured.fields + .map((e) => '${e.key}=${e.value}') + .toList(); + expect(fields, contains('title=')); + expect(fields, contains('description_md=')); + expect(fields, contains('video_url=')); + }, + ); + test('updateBlogPost - обрабатывает пустые поля', () async { - const id = 1; - when( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), + () => + mockService.updateBlogPost(id: 1, formData: any(named: 'formData')), ).thenAnswer((_) async => {}); - await repository.updateBlogPost(id, null, null, null, null, null); + await repository.updateBlogPost(id: 1); verify( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), - ).called(1); - }); - - test('createBlogPost - обрабатывает пустые поля', () async { - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(null, null, null, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('createBlogPost - обрабатывает только title', () async { - const title = 'Test Title'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(title, null, null, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('createBlogPost - обрабатывает только videoUrl', () async { - const videoUrl = 'https://example.com/video.mp4'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(null, videoUrl, null, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('createBlogPost - обрабатывает только description', () async { - const description = 'Test Description'; - - when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {}); - - await repository.createBlogPost(null, null, description, null); - - verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1); - }); - - test('updateBlogPost - обрабатывает все поля', () async { - const id = 1; - const title = 'Updated Title'; - const videoUrl = 'https://example.com/video.mp4'; - const description = 'Updated Description'; - - when( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), - ).thenAnswer((_) async => {}); - - await repository.updateBlogPost(id, title, videoUrl, description, null, null); - - verify( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), - ).called(1); - }); - - test('updateBlogPost - обрабатывает пустые поля', () async { - const id = 1; - - when( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), - ).thenAnswer((_) async => {}); - - await repository.updateBlogPost(id, null, null, null, null, null); - - verify( - () => mockService.updateBlogPost( - id: id, - formData: any(named: 'formData'), - ), + () => + mockService.updateBlogPost(id: 1, formData: any(named: 'formData')), ).called(1); }); }); diff --git a/test/features/blog_post_create/services/blog_post_service_test.dart b/test/features/blog_post_create/services/blog_post_service_test.dart index cbfcef01..8eca643f 100644 --- a/test/features/blog_post_create/services/blog_post_service_test.dart +++ b/test/features/blog_post_create/services/blog_post_service_test.dart @@ -18,8 +18,9 @@ void main() { test('createBlogPost - создает пост блога', () async { final formData = FormData.fromMap({}); - when(mockBlogPostService.createBlogPost(formData: formData)) - .thenAnswer((_) async => Future.value()); + when( + mockBlogPostService.createBlogPost(formData: formData), + ).thenAnswer((_) async => Future.value()); await mockBlogPostService.createBlogPost(formData: formData); @@ -30,19 +31,35 @@ void main() { const id = 1; final formData = FormData.fromMap({}); - when(mockBlogPostService.updateBlogPost(id: id, formData: formData)) - .thenAnswer((_) async => Future.value()); + when( + mockBlogPostService.updateBlogPost(id: id, formData: formData), + ).thenAnswer((_) async => Future.value()); await mockBlogPostService.updateBlogPost(id: id, formData: formData); - verify(mockBlogPostService.updateBlogPost(id: id, formData: formData)).called(1); + verify( + mockBlogPostService.updateBlogPost(id: id, formData: formData), + ).called(1); + }); + + test('deleteBlogPostMedia - удаляет изображение поста', () async { + when( + mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2), + ).thenAnswer((_) async => Future.value()); + + await mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2); + + verify( + mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2), + ).called(1); }); test('deleteBlogPost - удаляет пост блога', () async { const id = 1; - when(mockBlogPostService.deleteBlogPost(id: id)) - .thenAnswer((_) async => Future.value()); + when( + mockBlogPostService.deleteBlogPost(id: id), + ).thenAnswer((_) async => Future.value()); await mockBlogPostService.deleteBlogPost(id: id); @@ -50,4 +67,3 @@ void main() { }); }); } - diff --git a/test/features/blog_post_create/validation/blog_post_form_validator_test.dart b/test/features/blog_post_create/validation/blog_post_form_validator_test.dart new file mode 100644 index 00000000..27bfe366 --- /dev/null +++ b/test/features/blog_post_create/validation/blog_post_form_validator_test.dart @@ -0,0 +1,191 @@ +import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('BlogPostFormValidator.title', () { + test('allows empty', () { + expect(BlogPostFormValidator.title(''), isNull); + expect(BlogPostFormValidator.title(' '), isNull); + expect(BlogPostFormValidator.title(null), isNull); + }); + + test('rejects shorter than 3 chars', () { + expect( + BlogPostFormValidator.title('ab'), + BlogPostFormValidator.titleTooShort, + ); + expect( + BlogPostFormValidator.title(' a '), + BlogPostFormValidator.titleTooShort, + ); + }); + + test('allows 3+ chars', () { + expect(BlogPostFormValidator.title('abc'), isNull); + expect(BlogPostFormValidator.title(' hello '), isNull); + }); + }); + + group('BlogPostFormValidator.description', () { + test('allows empty and rejects short', () { + expect(BlogPostFormValidator.description(''), isNull); + expect( + BlogPostFormValidator.description('xy'), + BlogPostFormValidator.descriptionTooShort, + ); + expect(BlogPostFormValidator.description('xyz'), isNull); + }); + }); + + group('BlogPostFormValidator.videoUrl', () { + test('allows empty', () { + expect(BlogPostFormValidator.videoUrl(''), isNull); + expect(BlogPostFormValidator.videoUrl(' '), isNull); + }); + + test('rejects unsupported hosts', () { + expect( + BlogPostFormValidator.videoUrl('https://youtube.com/watch?v=abc'), + BlogPostFormValidator.videoUrlUnsupportedHost, + ); + expect( + BlogPostFormValidator.videoUrl('https://rutube.ru.evil.com/video/abc'), + BlogPostFormValidator.videoUrlUnsupportedHost, + ); + }); + + test('accepts rutube / vk / ok formats', () { + expect( + BlogPostFormValidator.videoUrl('https://rutube.ru/video/abc123/'), + isNull, + ); + expect( + BlogPostFormValidator.videoUrl('https://www.rutube.ru/shorts/abc123'), + isNull, + ); + expect( + BlogPostFormValidator.videoUrl('https://vk.com/video-123_456'), + isNull, + ); + expect( + BlogPostFormValidator.videoUrl('https://vkvideo.ru/video123_456/'), + isNull, + ); + expect( + BlogPostFormValidator.videoUrl('https://ok.ru/video/12345'), + isNull, + ); + }); + + test('rejects invalid path formats for allowed hosts', () { + expect( + BlogPostFormValidator.videoUrl('https://rutube.ru/channel/abc'), + BlogPostFormValidator.videoUrlRutubeFormat, + ); + expect( + BlogPostFormValidator.videoUrl('https://vk.com/feed'), + BlogPostFormValidator.videoUrlVkFormat, + ); + expect( + BlogPostFormValidator.videoUrl('https://ok.ru/profile/1'), + BlogPostFormValidator.videoUrlOkFormat, + ); + }); + + test('rejects malformed urls', () { + expect( + BlogPostFormValidator.videoUrl('not-a-url'), + BlogPostFormValidator.videoUrlInvalid, + ); + expect( + BlogPostFormValidator.videoUrl('ftp://rutube.ru/video/abc'), + BlogPostFormValidator.videoUrlInvalid, + ); + }); + }); + + group('BlogPostFormValidator.contentRequiredMessage', () { + test('requires at least one filled field', () { + expect( + BlogPostFormValidator.contentRequiredMessage( + title: '', + description: '', + videoUrl: '', + imageCount: 0, + ), + BlogPostFormValidator.contentRequired, + ); + }); + + test('accepts any valid content source', () { + expect( + BlogPostFormValidator.contentRequiredMessage( + title: 'abc', + description: '', + videoUrl: '', + imageCount: 0, + ), + isNull, + ); + expect( + BlogPostFormValidator.contentRequiredMessage( + title: '', + description: 'abc', + videoUrl: '', + imageCount: 0, + ), + isNull, + ); + expect( + BlogPostFormValidator.contentRequiredMessage( + title: '', + description: '', + videoUrl: '', + imageCount: 1, + ), + isNull, + ); + expect( + BlogPostFormValidator.contentRequiredMessage( + title: '', + description: '', + videoUrl: 'https://rutube.ru/video/abc123/', + imageCount: 0, + ), + isNull, + ); + }); + + test('does not count short title or invalid video as content', () { + expect( + BlogPostFormValidator.contentRequiredMessage( + title: 'ab', + description: '', + videoUrl: 'https://youtube.com/watch?v=1', + imageCount: 0, + ), + BlogPostFormValidator.contentRequired, + ); + }); + }); + + group('BlogPostFormValidator.imagePaths', () { + test('allows jpg/jpeg/png', () { + expect( + BlogPostFormValidator.imagePaths(['a.jpg', 'b.JPEG', 'c.png']), + isNull, + ); + }); + + test('rejects other extensions', () { + expect( + BlogPostFormValidator.imagePaths(['a.webp']), + BlogPostFormValidator.imageTypeInvalid, + ); + expect( + BlogPostFormValidator.imagePaths(['noext']), + BlogPostFormValidator.imageTypeInvalid, + ); + }); + }); +} diff --git a/test/fixtures/blog_post_create/create_request_full.json b/test/fixtures/blog_post_create/create_request_full.json index d329141e..bd6d15b6 100644 --- a/test/fixtures/blog_post_create/create_request_full.json +++ b/test/fixtures/blog_post_create/create_request_full.json @@ -1,7 +1,6 @@ { "title": "Complete Test Title", "description_md": "Complete Test Description", - "video_url": "https://youtube.com/watch?v=complete", + "video_url": "https://rutube.ru/video/12345/", "image_name": null } - diff --git a/test/fixtures/blog_post_create/update_request.json b/test/fixtures/blog_post_create/update_request.json index c3185f69..ae633780 100644 --- a/test/fixtures/blog_post_create/update_request.json +++ b/test/fixtures/blog_post_create/update_request.json @@ -2,8 +2,7 @@ "id": 1, "title": "Updated Title", "description_md": "Updated Description", - "video_url": "https://youtube.com/watch?v=updated", - "old_image_url": "original_image.jpg", + "video_url": "https://vk.com/video-1_1", + "existing_media_ids": [2, 1], "image_name": null } - diff --git a/test/fixtures/doubles/blog_post_doubles.dart b/test/fixtures/doubles/blog_post_doubles.dart index d79ca54c..ed59b75f 100644 --- a/test/fixtures/doubles/blog_post_doubles.dart +++ b/test/fixtures/doubles/blog_post_doubles.dart @@ -4,12 +4,12 @@ import 'package:eatvio_app/features/blog_post_create/services/blog_post_service. import 'package:flutter_image_compress/flutter_image_compress.dart'; class FixtureBlogPostRepository extends BlogPostRepository { - FixtureBlogPostRepository() - : super(blogPostService: _NoopBlogPostService()); + FixtureBlogPostRepository() : super(blogPostService: _NoopBlogPostService()); final List createPayloads = []; final List updatePayloads = []; final List deleteCalls = []; + final List<({int postId, int mediaId})> deleteMediaCalls = <({int postId, int mediaId})>[]; Duration createDelay = Duration.zero; Duration updateDelay = Duration.zero; @@ -18,15 +18,21 @@ class FixtureBlogPostRepository extends BlogPostRepository { Object? createError; Object? updateError; Object? deleteError; + Object? deleteMediaError; @override - Future createBlogPost(String? title, String? videoUrl, String? description, XFile? image) async { + Future createBlogPost({ + String? title, + String? videoUrl, + String? description, + List images = const [], + }) async { createPayloads.add( BlogPostCreatePayload( title: title, description: description, videoUrl: videoUrl, - imageName: image?.name, + imageName: images.isEmpty ? null : images.first.name, ), ); if (createDelay > Duration.zero) { @@ -38,15 +44,26 @@ class FixtureBlogPostRepository extends BlogPostRepository { } @override - Future updateBlogPost(int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl) async { + Future updateBlogPost({ + required int id, + String? title, + String? videoUrl, + String? description, + List existingMediaIds = const [], + List newImages = const [], + List mediaIdsToDelete = const [], + }) async { + for (final mediaId in mediaIdsToDelete) { + deleteMediaCalls.add((postId: id, mediaId: mediaId)); + } updatePayloads.add( BlogPostUpdatePayload( id: id, title: title, description: description, videoUrl: videoUrl, - imageName: image?.name, - oldImageUrl: oldImageUrl, + imageName: newImages.isEmpty ? null : newImages.first.name, + existingMediaIds: existingMediaIds, ), ); if (updateDelay > Duration.zero) { @@ -97,7 +114,7 @@ class BlogPostUpdatePayload { required this.description, required this.videoUrl, required this.imageName, - required this.oldImageUrl, + required this.existingMediaIds, }); final int id; @@ -105,15 +122,15 @@ class BlogPostUpdatePayload { final String? description; final String? videoUrl; final String? imageName; - final String? oldImageUrl; + final List existingMediaIds; Map toJson() => { 'id': id, 'title': title, 'description_md': description, 'video_url': videoUrl, + 'existing_media_ids': existingMediaIds, 'image_name': imageName, - 'old_image_url': oldImageUrl, }; } @@ -128,6 +145,11 @@ class _NoopBlogPostService implements BlogPostService { throw UnimplementedError('Fixture overrides repository methods directly.'); } + @override + Future deleteBlogPostMedia({required int postId, required int mediaId}) { + throw UnimplementedError('Fixture overrides repository methods directly.'); + } + @override Future updateBlogPost({required int id, required FormData formData}) { throw UnimplementedError('Fixture overrides repository methods directly.'); @@ -141,4 +163,3 @@ class FeedReloadProbe { loadRequests += 1; } } - From 478a53674a3038eb71cb9e5c01b7250caf6d7f61 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 5 Aug 2026 15:21:23 +0300 Subject: [PATCH 2/4] feat(post_creatin): fixed animation widget for profile feed --- .../presentation/pages/blog_post_screen.dart | 287 ++++++++++++++---- .../presentation/pages/photo_view_screen.dart | 50 ++- .../widgets/blog_post_media_content.dart | 85 ++++-- .../widgets/blog_post_widget.dart | 126 ++++++-- .../widgets/profile_blog_post_widget.dart | 137 +++++++-- .../profile_blog_post_widget_smoke_test.dart | 204 ++++++++++++- 6 files changed, 727 insertions(+), 162 deletions(-) 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 9067738c..e8840cd2 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -26,7 +26,11 @@ class BlogPostScreen extends StatefulWidget { final int id; final int? highlightedCommentId; - const BlogPostScreen({super.key, required this.id, this.highlightedCommentId}); + const BlogPostScreen({ + super.key, + required this.id, + this.highlightedCommentId, + }); @override State createState() => _BlogPostScreenState(); @@ -44,8 +48,11 @@ class _BlogPostScreenState extends State { super.initState(); } - void _openGallery(List imageUrls, int index) { - context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + void _openGallery(List imageUrls, int index, {Object? heroTag}) { + context.pushTransparentRoute( + rootNavigator: true, + PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag), + ); } @override @@ -53,7 +60,8 @@ class _BlogPostScreenState extends State { return BlocProvider( create: (context) => sl()..getBlogPost(widget.id), child: BlocProvider( - create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true), + create: (BuildContext context) => commentsCubit + ..getComments(widget.id.toString(), CommentSourceType.blogPost, true), child: BlocBuilder( builder: (context, blogPostState) { return BlocConsumer( @@ -81,15 +89,23 @@ class _BlogPostScreenState extends State { body: const Loader(), ); } - final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false; - final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0; + final bool isLiked = + blogPostState.blogPostModel?.wasLikedByMe ?? false; + final int totalLikes = + blogPostState.blogPostModel?.totalLikes ?? 0; return Scaffold( resizeToAvoidBottomInset: true, bottomSheet: Container( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom + 8, + ), decoration: BoxDecoration( color: Colors.white, - border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), + border: Border( + top: BorderSide( + color: Colors.black.withValues(alpha: 0.1), + ), + ), ), child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), @@ -107,10 +123,17 @@ class _BlogPostScreenState extends State { FocusManager.instance.primaryFocus?.unfocus(); }, child: Container( - decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), + decoration: BoxDecoration( + color: appTheme.blueA400, + shape: BoxShape.circle, + ), height: 32, width: 32, - child: const Icon(Icons.close_rounded, size: 18, color: Colors.white), + child: const Icon( + Icons.close_rounded, + size: 18, + color: Colors.white, + ), ), ), if (replyId != null) const SizedBox(width: 8), @@ -118,7 +141,8 @@ class _BlogPostScreenState extends State { child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 52), child: TextFormField( - textCapitalization: TextCapitalization.sentences, + textCapitalization: + TextCapitalization.sentences, onChanged: (_) { setState(() {}); }, @@ -126,26 +150,48 @@ class _BlogPostScreenState extends State { maxLines: 5, minLines: 1, keyboardType: TextInputType.multiline, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: Colors.black, + height: 1.2, + ), decoration: InputDecoration( isDense: false, // убираем true, чтобы padding учитывался корректно - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), // вертикальный паддинг увеличен hintText: S.of(context).comment_hint, - hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)), + hintStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + height: 1.2, + color: Color(0xFFAEAEB2), + ), filled: true, border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + borderSide: const BorderSide( + color: Color(0xFFD1D1D6), + width: 1, + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + borderSide: const BorderSide( + color: Color(0xFFD1D1D6), + width: 1, + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), + borderSide: const BorderSide( + color: Color(0xFFD1D1D6), + width: 1, + ), ), ), ), @@ -163,7 +209,10 @@ class _BlogPostScreenState extends State { ); if (!context.mounted) return; - if (isSent) context.read().incrementCommentsState(widget.id); + if (isSent) + context + .read() + .incrementCommentsState(widget.id); context.read().setReplyId(null); setState(() { @@ -174,10 +223,19 @@ class _BlogPostScreenState extends State { } }, child: Container( - decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle), + decoration: BoxDecoration( + color: textEditingController.text.length > 2 + ? appTheme.blueA400 + : Colors.grey, + shape: BoxShape.circle, + ), height: 32, width: 32, - child: const Icon(Icons.send_rounded, size: 18, color: Colors.white), + child: const Icon( + Icons.send_rounded, + size: 18, + color: Colors.white, + ), ), ), ], @@ -197,23 +255,33 @@ class _BlogPostScreenState extends State { ), actions: [ CustomPopup( - isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false, + isAuthor: + blogPostState.blogPostModel?.author.authorIsMe ?? + false, complainedContentType: ComplainedContent.posts, path: 'blog_post/${(widget.id)}', id: widget.id, feedBlogPostModel: blogPostState.blogPostModel, - userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '', + userSlug: + blogPostState.blogPostModel?.author.authorSlug ?? + '', ), const SizedBox(width: 16), ], ), body: SingleChildScrollView( - padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area + padding: EdgeInsets.only( + bottom: 160 + MediaQuery.of(context).padding.bottom, + ), // поле комментария (52-200) + padding (12+12+56) + safe area physics: const BouncingScrollPhysics(), child: Column( children: [ GestureDetector( - onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug), + onTap: () => GoRouter.of(context).pushNamed( + 'profileScreen', + extra: + blogPostState.blogPostModel?.author.authorSlug, + ), child: Ink( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( @@ -222,21 +290,46 @@ class _BlogPostScreenState extends State { children: [ CircleAvatar( radius: 16, - foregroundImage: NetworkImage(sl().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), + foregroundImage: NetworkImage( + sl().baseUrl + + (blogPostState + .blogPostModel + ?.author + .authorProfileIcon ?? + ''), + ), + backgroundImage: const AssetImage( + 'assets/images/no_avatar.png', + ), ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - blogPostState.blogPostModel?.author.authorName ?? '', - style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1), + blogPostState + .blogPostModel + ?.author + .authorName ?? + '', + style: const TextStyle( + color: Colors.black, + fontSize: 14, + fontWeight: FontWeight.w400, + height: 1, + ), ), const SizedBox(height: 2), Text( - (blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'), - style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1), + (blogPostState.blogPostModel?.createdAt ?? + DateTime.now()) + .format('dd MMMM', 'ru'), + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + fontWeight: FontWeight.w400, + height: 1, + ), ), ], ), @@ -244,10 +337,20 @@ class _BlogPostScreenState extends State { ), ), ), - if (blogPostState.blogPostModel?.description.isNotEmpty ?? false) + if (blogPostState + .blogPostModel + ?.description + .isNotEmpty ?? + false) Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: MarkdownHtmlWidget( + markdownText: + blogPostState.blogPostModel!.description, + ), ), const SizedBox(height: 8), Builder( @@ -256,15 +359,31 @@ class _BlogPostScreenState extends State { if (post == null) { return const SizedBox.shrink(); } - final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); + final imageUrl = PostFeedMapper.resolveMediaUrl( + post.image, + ); final embedUrl = post.videoEmbedUrl?.trim(); return BlogPostMediaContent( galleryImages: post.images, imageUrl: imageUrl, - videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, - videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, + videoEmbedUrl: + embedUrl != null && embedUrl.isNotEmpty + ? embedUrl + : null, + videoUrlThumb: post.videoUrlThumb != null + ? PostFeedMapper.resolveMediaUrl( + post.videoUrlThumb, + ) + : null, currentGalleryIndex: currentGalleryIndex, - onOpenGallery: _openGallery, + heroTag: 'blog-post-screen-${post.id}-cover', + onOpenGallery: (urls, index) => _openGallery( + urls, + index, + heroTag: urls.length == 1 + ? 'blog-post-screen-${post.id}-cover' + : null, + ), onGalleryChanged: (index) { setState(() { currentGalleryIndex = index; @@ -274,22 +393,43 @@ class _BlogPostScreenState extends State { }, ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), child: Row( children: [ _iconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + icon: isLiked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, text: totalLikes.toString(), - iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, - backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, - textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + iconColor: isLiked + ? Colors.red.withValues(alpha: 0.8) + : null, + backgroundColor: isLiked + ? Colors.red.withValues(alpha: 0.1) + : null, + textColor: isLiked + ? Colors.red.withValues(alpha: 0.8) + : null, onTap: () async { - await context.read().toggleLike(); + await context + .read() + .toggleLike(); // ignore: use_build_context_synchronously - final updated = context.read().state.blogPostModel; + final updated = context + .read() + .state + .blogPostModel; if (updated != null) { // ignore: use_build_context_synchronously - context.read().updateFeedBlogPostList(updated, updated.wasLikedByMe); + context + .read() + .updateFeedBlogPostList( + updated, + updated.wasLikedByMe, + ); } }, ), @@ -297,21 +437,36 @@ class _BlogPostScreenState extends State { _iconButton( icon: Icons.reply_rounded, onTap: () { - final box = context.findRenderObject() as RenderBox?; + final box = + context.findRenderObject() as RenderBox?; SharePlus.instance.share( ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${widget.id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + uri: Uri.parse( + '${sl().baseUrl}/ru/blog_post/${widget.id}', + ), + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & + box.size, ), ); }, ), const Spacer(), - Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), + Icon( + Icons.visibility_rounded, + size: 16, + color: appTheme.gray500, + ), const SizedBox(width: 4), Text( - blogPostState.blogPostModel?.totalViews.toString() ?? '', - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + blogPostState.blogPostModel?.totalViews + .toString() ?? + '', + style: TextStyle( + fontSize: 12, + color: appTheme.gray500, + fontWeight: FontWeight.w400, + ), ), ], ), @@ -319,7 +474,10 @@ class _BlogPostScreenState extends State { Container( width: MediaQuery.of(context).size.width - 32, height: 0.4, - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + ), ), const SizedBox(height: 8), Container( @@ -327,7 +485,11 @@ class _BlogPostScreenState extends State { alignment: Alignment.centerLeft, child: Text( '${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ', - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + style: TextStyle( + fontSize: 12, + color: appTheme.gray500, + fontWeight: FontWeight.w400, + ), ), ), const SizedBox(height: 16), @@ -352,13 +514,23 @@ class _BlogPostScreenState extends State { ); } - InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) { + InkWell _iconButton({ + required IconData icon, + String? text, + Function()? onTap, + Color? iconColor, + Color? backgroundColor, + Color? textColor, + }) { return InkWell( onTap: onTap ?? () {}, borderRadius: BorderRadius.circular(16), child: Ink( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray100, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration( + color: backgroundColor ?? appTheme.gray100, + borderRadius: BorderRadius.circular(16), + ), height: 32, child: Row( mainAxisSize: MainAxisSize.min, @@ -368,7 +540,10 @@ class _BlogPostScreenState extends State { if (text != null) Text( text, - style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500), + style: TextStyle( + color: textColor ?? appTheme.gray600, + fontWeight: FontWeight.w500, + ), ), ], ), diff --git a/lib/features/chat/presentation/pages/photo_view_screen.dart b/lib/features/chat/presentation/pages/photo_view_screen.dart index 3cbdc744..b9910feb 100644 --- a/lib/features/chat/presentation/pages/photo_view_screen.dart +++ b/lib/features/chat/presentation/pages/photo_view_screen.dart @@ -14,11 +14,15 @@ class PhotoViewScreen extends StatefulWidget { final int index; final bool isLocal; + /// When set, used instead of the image URL so source/destination tags stay unique. + final Object? heroTag; + const PhotoViewScreen({ super.key, required this.imageUrls, required this.index, this.isLocal = false, + this.heroTag, }); @override @@ -36,6 +40,23 @@ class _PhotoViewScreenState extends State { super.initState(); } + PhotoViewHeroAttributes? _heroAttributesFor(int index) { + if (widget.heroTag != null) { + if (index != widget.index) { + return null; + } + return PhotoViewHeroAttributes( + tag: widget.heroTag!, + transitionOnUserGestures: true, + ); + } + + return PhotoViewHeroAttributes( + tag: widget.imageUrls[index], + transitionOnUserGestures: true, + ); + } + @override Widget build(BuildContext context) { return DismissiblePage( @@ -44,23 +65,19 @@ class _PhotoViewScreenState extends State { direction: DismissiblePageDismissDirection.vertical, isFullScreen: true, child: PhotoViewGallery.builder( - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), + backgroundDecoration: const BoxDecoration(color: Colors.transparent), scrollPhysics: const BouncingScrollPhysics(), builder: (BuildContext context, int index) { final imageProvider = widget.isLocal ? FileImage(widget.imageUrls[index]) as ImageProvider - : CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider; + : CachedNetworkImageProvider(widget.imageUrls[index]) + as ImageProvider; return PhotoViewGalleryPageOptions( imageProvider: imageProvider, initialScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained, - heroAttributes: PhotoViewHeroAttributes( - tag: widget.imageUrls[index], - transitionOnUserGestures: true, - ), + heroAttributes: _heroAttributesFor(index), errorBuilder: (context, object, stackTrace) { return Scaffold( backgroundColor: Colors.black, @@ -76,8 +93,12 @@ class _PhotoViewScreenState extends State { ), contentPadding: const EdgeInsets.all(16), onTap: () async { - if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) { - throw Exception('Could not launch ${widget.imageUrls[index]}'); + if (!await launchUrl( + Uri.parse(widget.imageUrls[index]), + )) { + throw Exception( + 'Could not launch ${widget.imageUrls[index]}', + ); } }, leading: const Icon( @@ -89,7 +110,10 @@ class _PhotoViewScreenState extends State { color: Colors.white, ), title: Text( - widget.imageUrls[index].replaceAll('${sl().baseUrl}/storage/chat_files/', ''), + widget.imageUrls[index].replaceAll( + '${sl().baseUrl}/storage/chat_files/', + '', + ), style: const TextStyle( color: Colors.white, fontSize: 12, @@ -109,7 +133,9 @@ class _PhotoViewScreenState extends State { width: 20.0, height: 20.0, child: CircularProgressIndicator( - value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!, + value: event == null + ? 0 + : event.cumulativeBytesLoaded / event.expectedTotalBytes!, ), ), ), diff --git a/lib/features/feed/presentation/widgets/blog_post_media_content.dart b/lib/features/feed/presentation/widgets/blog_post_media_content.dart index a0d8ddc8..e0e87f31 100644 --- a/lib/features/feed/presentation/widgets/blog_post_media_content.dart +++ b/lib/features/feed/presentation/widgets/blog_post_media_content.dart @@ -18,6 +18,7 @@ class BlogPostMediaContent extends StatefulWidget { required this.onOpenGallery, required this.onGalleryChanged, required this.videoUrlThumb, + this.heroTag, }); final List galleryImages; @@ -28,6 +29,9 @@ class BlogPostMediaContent extends StatefulWidget { final void Function(List urls, int index) onOpenGallery; final ValueChanged onGalleryChanged; + /// Unique per post. Required when several cards with the same [imageUrl] share a route. + final Object? heroTag; + @override State createState() => _BlogPostMediaContentState(); } @@ -44,12 +48,14 @@ class _BlogPostMediaContentState extends State { bool _isCounterVisible = false; Timer? _counterHideTimer; - bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; + bool get _hasVideo => + widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty; List get _gallery { return [ for (final image in widget.galleryImages) - if (PostFeedMapper.resolveMediaUrl(image.url) case final url when url.isNotEmpty) + if (PostFeedMapper.resolveMediaUrl(image.url) case final url + when url.isNotEmpty) FeedGalleryImage( url: url, orientation: image.orientation, @@ -71,7 +77,8 @@ class _BlogPostMediaContentState extends State { return 0; } - bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == _gallery.length; + bool get _isCurrentSlideVideo => + _hasVideo && widget.currentGalleryIndex == _gallery.length; bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible; @@ -84,7 +91,9 @@ class _BlogPostMediaContentState extends State { } double _heightForOrientation(String orientation, double width) { - final aspectRatio = orientation == 'vertical' ? _verticalAspectRatio : _horizontalAspectRatio; + final aspectRatio = orientation == 'vertical' + ? _verticalAspectRatio + : _horizontalAspectRatio; final maxHeight = MediaQuery.sizeOf(context).height * 0.7; return (width / aspectRatio).clamp(180.0, maxHeight); } @@ -115,12 +124,15 @@ class _BlogPostMediaContentState extends State { } bool _handleCarouselScroll(ScrollNotification notification) { - if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) { + if (notification is ScrollStartNotification || + notification is ScrollUpdateNotification) { _counterHideTimer?.cancel(); if (!_isCounterVisible) { setState(() => _isCounterVisible = true); } - } else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) { + } else if (notification is ScrollEndNotification && + _isCounterVisible && + _isCurrentSlideVideo) { _scheduleCounterHideOnVideo(); } return false; @@ -140,17 +152,25 @@ class _BlogPostMediaContentState extends State { } Widget _videoSlide() { - return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb); + return FeedVideoPlayer( + embedUrl: widget.videoEmbedUrl!, + videoUrlThumb: widget.videoUrlThumb, + ); } - Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder, required double height}) { + Widget _carousel({ + required int itemCount, + required NullableIndexedWidgetBuilder itemBuilder, + required double height, + }) { return Stack( children: [ NotificationListener( onNotification: _handleCarouselScroll, child: CarouselSlider.builder( itemCount: itemCount, - itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(), + itemBuilder: (context, itemIndex, _) => + itemBuilder(context, itemIndex) ?? const SizedBox.shrink(), options: CarouselOptions( viewportFraction: 1, height: height, @@ -167,7 +187,10 @@ class _BlogPostMediaContentState extends State { child: AnimatedOpacity( opacity: _shouldShowCounter ? 1 : 0, duration: const Duration(milliseconds: 150), - child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: itemCount), + child: _GalleryPhotoCounter( + currentIndex: widget.currentGalleryIndex, + total: itemCount, + ), ), ), ), @@ -203,25 +226,21 @@ class _BlogPostMediaContentState extends State { } if (_hasVideo) { - return SizedBox( - height: mediaHeight, - width: width, - child: _videoSlide(), - ); + return SizedBox(height: mediaHeight, width: width, child: _videoSlide()); } if (widget.imageUrl.isNotEmpty) { + final image = CachedNetworkImage( + fit: BoxFit.fitWidth, + width: width, + imageUrl: widget.imageUrl, + errorWidget: (context, str, object) => const SizedBox.shrink(), + ); return GestureDetector( onTap: () => widget.onOpenGallery([widget.imageUrl], 0), - child: Hero( - tag: widget.imageUrl, - child: CachedNetworkImage( - fit: BoxFit.fitWidth, - width: width, - imageUrl: widget.imageUrl, - errorWidget: (context, str, object) => const SizedBox.shrink(), - ), - ), + child: widget.heroTag == null + ? image + : Hero(tag: widget.heroTag!, child: image), ); } @@ -239,15 +258,27 @@ class _GalleryPhotoCounter extends StatelessWidget { Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(999), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white), + const Icon( + Icons.photo_library_outlined, + size: 14, + color: Colors.white, + ), const SizedBox(width: 8), Text( '${currentIndex + 1} / $total', - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.2, + ), ), ], ), diff --git a/lib/features/feed/presentation/widgets/blog_post_widget.dart b/lib/features/feed/presentation/widgets/blog_post_widget.dart index 3977d462..ac467501 100644 --- a/lib/features/feed/presentation/widgets/blog_post_widget.dart +++ b/lib/features/feed/presentation/widgets/blog_post_widget.dart @@ -51,14 +51,18 @@ class _BlogPostWidgetState extends State { totalLikes = post.totalLikes; return; } - if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || oldWidget.post.totalLikes != post.totalLikes) { + if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || + oldWidget.post.totalLikes != post.totalLikes) { isLiked = post.wasLikedByMe; totalLikes = post.totalLikes; } } - void _openGallery(List imageUrls, int index) { - context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + void _openGallery(List imageUrls, int index, {Object? heroTag}) { + context.pushTransparentRoute( + rootNavigator: true, + PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag), + ); } Future _toggleLike() async { @@ -82,7 +86,8 @@ class _BlogPostWidgetState extends State { return InkWell( borderRadius: BorderRadius.circular(12), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + onTap: () => + GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), child: Material( borderRadius: BorderRadius.circular(12), color: Colors.white, @@ -96,10 +101,19 @@ class _BlogPostWidgetState extends State { BlogPostMediaContent( galleryImages: post.images, imageUrl: imageUrl, - videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty + ? embedUrl + : null, videoUrlThumb: post.videoUrlThumb, currentGalleryIndex: currentGalleryIndex, - onOpenGallery: _openGallery, + heroTag: 'feed-blog-post-${post.id}-cover', + onOpenGallery: (urls, index) => _openGallery( + urls, + index, + heroTag: urls.length == 1 + ? 'feed-blog-post-${post.id}-cover' + : null, + ), onGalleryChanged: (index) { setState(() { currentGalleryIndex = index; @@ -107,7 +121,12 @@ class _BlogPostWidgetState extends State { }, ), const SizedBox(height: 12), - _BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike), + _BlogPostActionsRow( + post: post, + isLiked: isLiked, + totalLikes: totalLikes, + onToggleLike: _toggleLike, + ), const SizedBox(height: 8), ], ), @@ -129,11 +148,19 @@ class _BlogPostHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( - onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug), + onTap: () => GoRouter.of( + context, + ).pushNamed('profileScreen', extra: post.author.authorSlug), child: CircleAvatar( radius: 20, backgroundColor: Colors.black12, - foregroundImage: post.author.authorProfileIcon.isNotEmpty ? NetworkImage(PostFeedMapper.resolveMediaUrl(post.author.authorProfileIcon)) : null, + foregroundImage: post.author.authorProfileIcon.isNotEmpty + ? NetworkImage( + PostFeedMapper.resolveMediaUrl( + post.author.authorProfileIcon, + ), + ) + : null, backgroundImage: const AssetImage('assets/images/no_avatar.png'), ), ), @@ -145,17 +172,27 @@ class _BlogPostHeader extends StatelessWidget { Text( post.author.authorName, overflow: TextOverflow.ellipsis, - style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16), + style: const TextStyle( + height: 1, + color: Colors.black87, + fontWeight: FontWeight.w500, + fontSize: 16, + ), ), const SizedBox(height: 4), Text( '${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}', - style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14), + style: const TextStyle( + height: 1, + color: Colors.black45, + fontSize: 14, + ), ), ], ), ), - if (!(post.author.isEntitySubscribedByMe ?? false) && !post.author.authorIsMe) + if (!(post.author.isEntitySubscribedByMe ?? false) && + !post.author.authorIsMe) Padding( padding: const EdgeInsets.only(right: 8), child: SubscribeButtonWidget(slug: post.author.authorSlug), @@ -190,33 +227,51 @@ class _BlogPostTextContent extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( post.title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), ), ), ], if (post.title.isEmpty) const SizedBox(height: 8), - if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty && post.description.isNotEmpty) + const SizedBox(height: 8), if (post.description.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), - child: MarkdownHtmlWidget(markdownText: _truncateDescription(post.description)), + child: MarkdownHtmlWidget( + markdownText: _truncateDescription(post.description), + ), ), if (post.description.length > 200) const Padding( padding: EdgeInsets.symmetric(horizontal: 12), child: Text( 'Показать ещё', - style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), + style: TextStyle( + height: 1, + color: Color(0xFF8B8B8B), + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), ), - if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty || post.description.isNotEmpty) + const SizedBox(height: 8), ], ); } } class _BlogPostActionsRow extends StatelessWidget { - const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike}); + const _BlogPostActionsRow({ + required this.post, + required this.isLiked, + required this.totalLikes, + required this.onToggleLike, + }); final FeedBlogPostModel post; final bool isLiked; @@ -230,7 +285,9 @@ class _BlogPostActionsRow extends StatelessWidget { child: Row( children: [ FeedIconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + icon: isLiked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, text: totalLikes.toString(), iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, @@ -239,19 +296,34 @@ class _BlogPostActionsRow extends StatelessWidget { ), const SizedBox(width: 8), FeedIconButton( - svgIcon: SvgPicture.asset('assets/icons/comment.svg', height: 20, width: 20, colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)), + svgIcon: SvgPicture.asset( + 'assets/icons/comment.svg', + height: 20, + width: 20, + colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + ), text: post.totalComments.toString(), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + onTap: () => GoRouter.of( + context, + ).pushNamed('blogPostScreen', extra: post.id), ), const SizedBox(width: 8), FeedIconButton( - svgIcon: SvgPicture.asset(height: 15, width: 12, 'assets/icons/repost.svg', colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)), + svgIcon: SvgPicture.asset( + height: 15, + width: 12, + 'assets/icons/repost.svg', + colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + ), onTap: () { final box = context.findRenderObject() as RenderBox?; SharePlus.instance.share( ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${post.id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + uri: Uri.parse( + '${sl().baseUrl}/ru/blog_post/${post.id}', + ), + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & box.size, ), ); }, @@ -261,7 +333,11 @@ class _BlogPostActionsRow extends StatelessWidget { const SizedBox(width: 4), Text( post.totalViews.toString(), - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + style: TextStyle( + fontSize: 12, + color: appTheme.gray500, + fontWeight: FontWeight.w400, + ), ), ], ), diff --git a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart index 2f19f12a..af70c9df 100644 --- a/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart +++ b/lib/features/profile/presentation/widgets/profile_blog_post_widget.dart @@ -24,7 +24,11 @@ class ProfileBlogPostWidget extends StatefulWidget { final int index; final ProfileCubit profileCubit; - const ProfileBlogPostWidget({super.key, required this.index, required this.profileCubit}); + const ProfileBlogPostWidget({ + super.key, + required this.index, + required this.profileCubit, + }); @override State createState() => _ProfileBlogPostWidgetState(); @@ -35,8 +39,11 @@ class _ProfileBlogPostWidgetState extends State { late int totalLikes; int currentGalleryIndex = 0; - void _openGallery(List imageUrls, int index) { - context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index)); + void _openGallery(List imageUrls, int index, {Object? heroTag}) { + context.pushTransparentRoute( + rootNavigator: true, + PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag), + ); } @override @@ -56,9 +63,13 @@ class _ProfileBlogPostWidgetState extends State { return InkWell( borderRadius: BorderRadius.circular(12), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + onTap: () => + GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), child: Ink( - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -74,9 +85,12 @@ class _ProfileBlogPostWidgetState extends State { foregroundImage: NetworkImage( post.author.authorProfileIcon.contains('https') ? post.author.authorProfileIcon - : sl().baseUrl + post.author.authorProfileIcon, + : sl().baseUrl + + post.author.authorProfileIcon, + ), + backgroundImage: const AssetImage( + 'assets/images/no_avatar.png', ), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), ), const SizedBox(width: 8), Column( @@ -84,12 +98,19 @@ class _ProfileBlogPostWidgetState extends State { children: [ Text( post.author.authorName, - style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500), + style: const TextStyle( + height: 1, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), ), const SizedBox(height: 4), Text( '${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}', - style: const TextStyle(height: 1, color: Colors.black45), + style: const TextStyle( + height: 1, + color: Colors.black45, + ), ), ], ), @@ -112,12 +133,17 @@ class _ProfileBlogPostWidgetState extends State { padding: const EdgeInsets.symmetric(horizontal: 12), child: Text( post.title, - style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: Colors.black87, + ), ), ), ], if (post.title.isEmpty) const SizedBox(height: 8), - if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty && post.description.isNotEmpty) + const SizedBox(height: 8), if (post.description.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 12), @@ -132,17 +158,34 @@ class _ProfileBlogPostWidgetState extends State { padding: EdgeInsets.symmetric(horizontal: 12), child: Text( 'Показать ещё', - style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), + style: TextStyle( + height: 1, + color: Color(0xFF8B8B8B), + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), ), - if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8), + if (post.title.isNotEmpty || post.description.isNotEmpty) + const SizedBox(height: 8), BlogPostMediaContent( galleryImages: post.images, imageUrl: imageUrl, - videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, - videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty + ? embedUrl + : null, + videoUrlThumb: post.videoUrlThumb != null + ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) + : null, currentGalleryIndex: currentGalleryIndex, - onOpenGallery: _openGallery, + heroTag: 'profile-blog-post-${post.id}-cover', + onOpenGallery: (urls, index) => _openGallery( + urls, + index, + heroTag: urls.length == 1 + ? 'profile-blog-post-${post.id}-cover' + : null, + ), onGalleryChanged: (index) { setState(() { currentGalleryIndex = index; @@ -155,18 +198,35 @@ class _ProfileBlogPostWidgetState extends State { child: Row( children: [ FeedIconButton( - icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + icon: isLiked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, text: totalLikes.toString(), - iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, - backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, - textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + iconColor: isLiked + ? Colors.red.withValues(alpha: 0.8) + : null, + backgroundColor: isLiked + ? Colors.red.withValues(alpha: 0.1) + : null, + textColor: isLiked + ? Colors.red.withValues(alpha: 0.8) + : null, onTap: () async { setState(() { isLiked = !isLiked; isLiked ? totalLikes++ : totalLikes--; }); - isLiked ? await context.read().likePost(post.id) : await context.read().unlikedPost(post.id); - widget.profileCubit.updateFeedBlogPostList(post, isLiked ? false : true); + isLiked + ? await context.read().likePost( + post.id, + ) + : await context.read().unlikedPost( + post.id, + ); + widget.profileCubit.updateFeedBlogPostList( + post, + isLiked ? false : true, + ); }, ), const SizedBox(width: 8), @@ -175,10 +235,15 @@ class _ProfileBlogPostWidgetState extends State { 'assets/icons/comment.svg', height: 20, width: 20, - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + colorFilter: ColorFilter.mode( + appTheme.gray600, + BlendMode.srcIn, + ), ), text: post.totalComments.toString(), - onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id), + onTap: () => GoRouter.of( + context, + ).pushNamed('blogPostScreen', extra: post.id), ), const SizedBox(width: 8), FeedIconButton( @@ -186,24 +251,38 @@ class _ProfileBlogPostWidgetState extends State { height: 20, width: 20, 'assets/icons/share.svg', - colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn), + colorFilter: ColorFilter.mode( + appTheme.gray600, + BlendMode.srcIn, + ), ), onTap: () { final box = context.findRenderObject() as RenderBox?; SharePlus.instance.share( ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${post.id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + uri: Uri.parse( + '${sl().baseUrl}/ru/blog_post/${post.id}', + ), + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & box.size, ), ); }, ), const Spacer(), - Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), + Icon( + Icons.visibility_rounded, + size: 16, + color: appTheme.gray500, + ), const SizedBox(width: 4), Text( post.totalViews.toString(), - style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), + style: TextStyle( + fontSize: 12, + color: appTheme.gray500, + fontWeight: FontWeight.w400, + ), ), ], ), 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 44ab062a..4102ca44 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 @@ -47,13 +47,77 @@ class _FakeHttpClientRequest implements HttpClientRequest { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -class _FakeHttpClientResponse extends Stream> implements HttpClientResponse { +class _FakeHttpClientResponse extends Stream> + implements HttpClientResponse { // 1x1 transparent PNG. static final List _png = [ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, - 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, - 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, - 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, + 0x42, + 0x60, + 0x82, ]; @override @@ -63,11 +127,22 @@ class _FakeHttpClientResponse extends Stream> implements HttpClientRes int get contentLength => _png.length; @override - HttpClientResponseCompressionState get compressionState => HttpClientResponseCompressionState.notCompressed; + HttpClientResponseCompressionState get compressionState => + HttpClientResponseCompressionState.notCompressed; @override - StreamSubscription> listen(void Function(List)? onData, {Function? onError, void Function()? onDone, bool? cancelOnError}) { - return Stream>.fromIterable([_png]).listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError); + StreamSubscription> listen( + void Function(List)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return Stream>.fromIterable([_png]).listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); } @override @@ -78,12 +153,18 @@ class _FakeHttpClientResponse extends Stream> implements HttpClientRes void main() { group('ProfileBlogPostWidget smoke', () { setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + dotenv.loadFromString( + envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost', + ); }); setUp(() async { await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton( + () => + AppConfig(buildType: AppBuildType.tests) + ..setBaseUrl('http://localhost'), + ); }); const profile = ProfileModel( @@ -160,7 +241,13 @@ void main() { testWidgets('renders gallery and video media content', (tester) async { final post = FeedFixtures.feedBlogPostGalleryAndVideo(); - final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); + 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()); @@ -174,7 +261,13 @@ void main() { testWidgets('renders gallery-only media content', (tester) async { final post = FeedFixtures.feedBlogPostWithGallery(); - final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); + 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()); @@ -188,7 +281,13 @@ void main() { testWidgets('renders video-only media content', (tester) async { final post = FeedFixtures.feedBlogPostVideoOnly(); - final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []); + 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()); @@ -199,5 +298,84 @@ void main() { expect(find.byType(BlogPostMediaContent), findsOneWidget); expect(find.text('Видео'), findsOneWidget); }); + + testWidgets('cover-only posts with the same image use unique hero tags', ( + tester, + ) async { + const sharedImage = 'https://example.com/same-cover.jpg'; + final posts = [ + FeedFixtures.feedBlogPostModel().copyWith( + id: 11, + title: 'Первый', + description: 'Текст', + image: sharedImage, + images: const [], + videoUrl: '', + videoEmbedUrl: null, + ), + FeedFixtures.feedBlogPostModel().copyWith( + id: 22, + title: 'Второй', + description: 'Текст', + image: sharedImage, + images: const [], + videoUrl: '', + videoEmbedUrl: null, + ), + ]; + final loadedState = ProfileState.loaded( + profile: profile, + profileDetails: details, + blogPosts: posts, + isMyProfile: true, + certPhotos: [], + ); + final cubit = MockProfileCubit(); + when(cubit.state).thenReturn(loadedState); + when(cubit.stream).thenAnswer((_) => const Stream.empty()); + when(cubit.close()).thenAnswer((_) async => Future.value()); + + await HttpOverrides.runZoned(() async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeHelper().themeData(), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('ru')], + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(size: Size(800, 1600)), + child: SingleChildScrollView( + child: Column( + children: [ + ProfileBlogPostWidget(index: 0, profileCubit: cubit), + ProfileBlogPostWidget(index: 1, profileCubit: cubit), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(const Duration(milliseconds: 200)); + }, createHttpClient: (_) => _FakeHttpClient()); + + final tags = tester + .widgetList(find.byType(Hero)) + .map((hero) => hero.tag) + .toList(); + expect( + tags, + containsAll([ + 'profile-blog-post-11-cover', + 'profile-blog-post-22-cover', + ]), + ); + expect(tags.toSet().length, tags.length); + }); }); } From 6b5442eb872900ebdd24d5599782af708206925f Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 5 Aug 2026 15:33:27 +0300 Subject: [PATCH 3/4] fix: analyzer fix --- .../presentation/pages/blog_post_screen.dart | 284 ++++-------------- 1 file changed, 55 insertions(+), 229 deletions(-) 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 e8840cd2..915a61fb 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -26,11 +26,7 @@ class BlogPostScreen extends StatefulWidget { final int id; final int? highlightedCommentId; - const BlogPostScreen({ - super.key, - required this.id, - this.highlightedCommentId, - }); + const BlogPostScreen({super.key, required this.id, this.highlightedCommentId}); @override State createState() => _BlogPostScreenState(); @@ -49,10 +45,7 @@ class _BlogPostScreenState extends State { } void _openGallery(List imageUrls, int index, {Object? heroTag}) { - context.pushTransparentRoute( - rootNavigator: true, - PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag), - ); + context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag)); } @override @@ -60,8 +53,7 @@ class _BlogPostScreenState extends State { return BlocProvider( create: (context) => sl()..getBlogPost(widget.id), child: BlocProvider( - create: (BuildContext context) => commentsCubit - ..getComments(widget.id.toString(), CommentSourceType.blogPost, true), + create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true), child: BlocBuilder( builder: (context, blogPostState) { return BlocConsumer( @@ -89,23 +81,15 @@ class _BlogPostScreenState extends State { body: const Loader(), ); } - final bool isLiked = - blogPostState.blogPostModel?.wasLikedByMe ?? false; - final int totalLikes = - blogPostState.blogPostModel?.totalLikes ?? 0; + final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false; + final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0; return Scaffold( resizeToAvoidBottomInset: true, bottomSheet: Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom + 8, - ), + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), decoration: BoxDecoration( color: Colors.white, - border: Border( - top: BorderSide( - color: Colors.black.withValues(alpha: 0.1), - ), - ), + border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), ), child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), @@ -123,17 +107,10 @@ class _BlogPostScreenState extends State { FocusManager.instance.primaryFocus?.unfocus(); }, child: Container( - decoration: BoxDecoration( - color: appTheme.blueA400, - shape: BoxShape.circle, - ), + decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), height: 32, width: 32, - child: const Icon( - Icons.close_rounded, - size: 18, - color: Colors.white, - ), + child: const Icon(Icons.close_rounded, size: 18, color: Colors.white), ), ), if (replyId != null) const SizedBox(width: 8), @@ -141,8 +118,7 @@ class _BlogPostScreenState extends State { child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 52), child: TextFormField( - textCapitalization: - TextCapitalization.sentences, + textCapitalization: TextCapitalization.sentences, onChanged: (_) { setState(() {}); }, @@ -150,48 +126,26 @@ class _BlogPostScreenState extends State { maxLines: 5, minLines: 1, keyboardType: TextInputType.multiline, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Colors.black, - height: 1.2, - ), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2), decoration: InputDecoration( isDense: false, // убираем true, чтобы padding учитывался корректно - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), // вертикальный паддинг увеличен hintText: S.of(context).comment_hint, - hintStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - height: 1.2, - color: Color(0xFFAEAEB2), - ), + hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)), filled: true, border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide( - color: Color(0xFFD1D1D6), - width: 1, - ), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide( - color: Color(0xFFD1D1D6), - width: 1, - ), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide( - color: Color(0xFFD1D1D6), - width: 1, - ), + borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), ), ), ), @@ -209,10 +163,7 @@ class _BlogPostScreenState extends State { ); if (!context.mounted) return; - if (isSent) - context - .read() - .incrementCommentsState(widget.id); + if (isSent) context.read().incrementCommentsState(widget.id); context.read().setReplyId(null); setState(() { @@ -223,19 +174,10 @@ class _BlogPostScreenState extends State { } }, child: Container( - decoration: BoxDecoration( - color: textEditingController.text.length > 2 - ? appTheme.blueA400 - : Colors.grey, - shape: BoxShape.circle, - ), + decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle), height: 32, width: 32, - child: const Icon( - Icons.send_rounded, - size: 18, - color: Colors.white, - ), + child: const Icon(Icons.send_rounded, size: 18, color: Colors.white), ), ), ], @@ -255,33 +197,23 @@ class _BlogPostScreenState extends State { ), actions: [ CustomPopup( - isAuthor: - blogPostState.blogPostModel?.author.authorIsMe ?? - false, + isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false, complainedContentType: ComplainedContent.posts, path: 'blog_post/${(widget.id)}', id: widget.id, feedBlogPostModel: blogPostState.blogPostModel, - userSlug: - blogPostState.blogPostModel?.author.authorSlug ?? - '', + userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '', ), const SizedBox(width: 16), ], ), body: SingleChildScrollView( - padding: EdgeInsets.only( - bottom: 160 + MediaQuery.of(context).padding.bottom, - ), // поле комментария (52-200) + padding (12+12+56) + safe area + padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area physics: const BouncingScrollPhysics(), child: Column( children: [ GestureDetector( - onTap: () => GoRouter.of(context).pushNamed( - 'profileScreen', - extra: - blogPostState.blogPostModel?.author.authorSlug, - ), + onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug), child: Ink( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( @@ -290,46 +222,21 @@ class _BlogPostScreenState extends State { children: [ CircleAvatar( radius: 16, - foregroundImage: NetworkImage( - sl().baseUrl + - (blogPostState - .blogPostModel - ?.author - .authorProfileIcon ?? - ''), - ), - backgroundImage: const AssetImage( - 'assets/images/no_avatar.png', - ), + foregroundImage: NetworkImage(sl().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')), + backgroundImage: const AssetImage('assets/images/no_avatar.png'), ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - blogPostState - .blogPostModel - ?.author - .authorName ?? - '', - style: const TextStyle( - color: Colors.black, - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1, - ), + blogPostState.blogPostModel?.author.authorName ?? '', + style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1), ), const SizedBox(height: 2), Text( - (blogPostState.blogPostModel?.createdAt ?? - DateTime.now()) - .format('dd MMMM', 'ru'), - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 12, - fontWeight: FontWeight.w400, - height: 1, - ), + (blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'), + style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1), ), ], ), @@ -337,20 +244,10 @@ class _BlogPostScreenState extends State { ), ), ), - if (blogPostState - .blogPostModel - ?.description - .isNotEmpty ?? - false) + if (blogPostState.blogPostModel?.description.isNotEmpty ?? false) Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: MarkdownHtmlWidget( - markdownText: - blogPostState.blogPostModel!.description, - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description), ), const SizedBox(height: 8), Builder( @@ -359,31 +256,16 @@ class _BlogPostScreenState extends State { if (post == null) { return const SizedBox.shrink(); } - final imageUrl = PostFeedMapper.resolveMediaUrl( - post.image, - ); + final imageUrl = PostFeedMapper.resolveMediaUrl(post.image); final embedUrl = post.videoEmbedUrl?.trim(); return BlogPostMediaContent( galleryImages: post.images, imageUrl: imageUrl, - videoEmbedUrl: - embedUrl != null && embedUrl.isNotEmpty - ? embedUrl - : null, - videoUrlThumb: post.videoUrlThumb != null - ? PostFeedMapper.resolveMediaUrl( - post.videoUrlThumb, - ) - : null, + videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null, + videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null, currentGalleryIndex: currentGalleryIndex, heroTag: 'blog-post-screen-${post.id}-cover', - onOpenGallery: (urls, index) => _openGallery( - urls, - index, - heroTag: urls.length == 1 - ? 'blog-post-screen-${post.id}-cover' - : null, - ), + onOpenGallery: (urls, index) => _openGallery(urls, index, heroTag: urls.length == 1 ? 'blog-post-screen-${post.id}-cover' : null), onGalleryChanged: (index) { setState(() { currentGalleryIndex = index; @@ -393,43 +275,22 @@ class _BlogPostScreenState extends State { }, ), Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ _iconButton( - icon: isLiked - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, + icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, text: totalLikes.toString(), - iconColor: isLiked - ? Colors.red.withValues(alpha: 0.8) - : null, - backgroundColor: isLiked - ? Colors.red.withValues(alpha: 0.1) - : null, - textColor: isLiked - ? Colors.red.withValues(alpha: 0.8) - : null, + iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, + backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, + textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, onTap: () async { - await context - .read() - .toggleLike(); + await context.read().toggleLike(); // ignore: use_build_context_synchronously - final updated = context - .read() - .state - .blogPostModel; + final updated = context.read().state.blogPostModel; if (updated != null) { // ignore: use_build_context_synchronously - context - .read() - .updateFeedBlogPostList( - updated, - updated.wasLikedByMe, - ); + context.read().updateFeedBlogPostList(updated, updated.wasLikedByMe); } }, ), @@ -437,36 +298,21 @@ class _BlogPostScreenState extends State { _iconButton( icon: Icons.reply_rounded, onTap: () { - final box = - context.findRenderObject() as RenderBox?; + final box = context.findRenderObject() as RenderBox?; SharePlus.instance.share( ShareParams( - uri: Uri.parse( - '${sl().baseUrl}/ru/blog_post/${widget.id}', - ), - sharePositionOrigin: - box!.localToGlobal(Offset.zero) & - box.size, + uri: Uri.parse('${sl().baseUrl}/ru/blog_post/${widget.id}'), + sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, ), ); }, ), const Spacer(), - Icon( - Icons.visibility_rounded, - size: 16, - color: appTheme.gray500, - ), + Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), const SizedBox(width: 4), Text( - blogPostState.blogPostModel?.totalViews - .toString() ?? - '', - style: TextStyle( - fontSize: 12, - color: appTheme.gray500, - fontWeight: FontWeight.w400, - ), + blogPostState.blogPostModel?.totalViews.toString() ?? '', + style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), ), ], ), @@ -474,10 +320,7 @@ class _BlogPostScreenState extends State { Container( width: MediaQuery.of(context).size.width - 32, height: 0.4, - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(16), - ), + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)), ), const SizedBox(height: 8), Container( @@ -485,11 +328,7 @@ class _BlogPostScreenState extends State { alignment: Alignment.centerLeft, child: Text( '${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ', - style: TextStyle( - fontSize: 12, - color: appTheme.gray500, - fontWeight: FontWeight.w400, - ), + style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), ), ), const SizedBox(height: 16), @@ -514,23 +353,13 @@ class _BlogPostScreenState extends State { ); } - InkWell _iconButton({ - required IconData icon, - String? text, - Function()? onTap, - Color? iconColor, - Color? backgroundColor, - Color? textColor, - }) { + InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) { return InkWell( onTap: onTap ?? () {}, borderRadius: BorderRadius.circular(16), child: Ink( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: backgroundColor ?? appTheme.gray100, - borderRadius: BorderRadius.circular(16), - ), + decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray100, borderRadius: BorderRadius.circular(16)), height: 32, child: Row( mainAxisSize: MainAxisSize.min, @@ -540,10 +369,7 @@ class _BlogPostScreenState extends State { if (text != null) Text( text, - style: TextStyle( - color: textColor ?? appTheme.gray600, - fontWeight: FontWeight.w500, - ), + style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500), ), ], ), From 0d588bcd5747c3a086677b058b6e3ad61d190e11 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 5 Aug 2026 16:32:14 +0300 Subject: [PATCH 4/4] Version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 21b16284..9e654300 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.37+126 +version: 1.3.38+127 publish_to: 'none' environment: