forked from eatvio/eatvio_app
Compare commits
24 Commits
promo_code
...
pasport_da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8494e61eec | ||
|
|
5c2458ecad | ||
| b63bab8bf5 | |||
| 7790014726 | |||
|
|
0d588bcd57 | ||
| b679e90ba2 | |||
|
|
6b5442eb87 | ||
| 2474c5bea3 | |||
|
|
478a53674a | ||
| 7f278d0f59 | |||
|
|
53426c858c | ||
| 88f0cde88f | |||
|
|
a5fcf62524 | ||
| a2e41a2ff5 | |||
|
|
5b1beafbab | ||
| 4eadecd6f2 | |||
|
|
6292db07a1 | ||
| ab29b6b329 | |||
|
|
af5d182a79 | ||
|
|
04de956769 | ||
| 191bb74aa4 | |||
|
|
3210f38d4a | ||
| 27a98a496d | |||
| d8ecc7f3b1 |
@@ -59,12 +59,16 @@ jobs:
|
|||||||
- name: Flutter build
|
- name: Flutter build
|
||||||
run: |
|
run: |
|
||||||
flutter --version
|
flutter --version
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
rm -rf ios/Podfile.lock
|
||||||
flutter clean
|
flutter clean
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter pub run build_runner build --delete-conflicting-outputs
|
flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
flutter build ios --config-only --release
|
|
||||||
cd ios
|
cd ios
|
||||||
pod install
|
pod install --repo-update
|
||||||
|
cd ..
|
||||||
|
flutter build ios --config-only --release
|
||||||
|
cd ios
|
||||||
fastlane beta
|
fastlane beta
|
||||||
env:
|
env:
|
||||||
LANG: en_US.UTF-8
|
LANG: en_US.UTF-8
|
||||||
|
|||||||
@@ -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/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/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/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/diets_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/recipes_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/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/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/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:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
@@ -30,6 +33,7 @@ class CustomPopup extends StatelessWidget {
|
|||||||
final Recipe? recipe;
|
final Recipe? recipe;
|
||||||
final Diet? diet;
|
final Diet? diet;
|
||||||
final String userSlug;
|
final String userSlug;
|
||||||
|
final VoidCallback? onPostsChanged;
|
||||||
|
|
||||||
const CustomPopup({
|
const CustomPopup({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -41,7 +45,8 @@ class CustomPopup extends StatelessWidget {
|
|||||||
this.feedBlogPostModel,
|
this.feedBlogPostModel,
|
||||||
this.recipe,
|
this.recipe,
|
||||||
this.diet,
|
this.diet,
|
||||||
required this.userSlug
|
required this.userSlug,
|
||||||
|
this.onPostsChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -103,7 +108,8 @@ class CustomPopup extends StatelessWidget {
|
|||||||
if (complainedContentType == ComplainedContent.posts) {
|
if (complainedContentType == ComplainedContent.posts) {
|
||||||
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
||||||
if (complainedContentType == ComplainedContent.posts) {
|
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);
|
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showSuccessToast('Пост удален, обновите ленту');
|
onPostsChanged?.call();
|
||||||
|
ProfileCubit.myProfileCubit?.reloadBlogPosts();
|
||||||
|
if (context.mounted) {
|
||||||
|
try {
|
||||||
|
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
showSuccessToast('Пост удален');
|
||||||
} else if (complainedContentType == ComplainedContent.recipe) {
|
} else if (complainedContentType == ComplainedContent.recipe) {
|
||||||
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
|
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
|
||||||
if (!result && context.mounted) {
|
if (!result && context.mounted) {
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openGallery(List<String> imageUrls, int index) {
|
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -264,7 +264,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||||
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
||||||
currentGalleryIndex: currentGalleryIndex,
|
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) {
|
onGalleryChanged: (index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
currentGalleryIndex = index;
|
currentGalleryIndex = index;
|
||||||
|
|||||||
@@ -3,38 +3,184 @@ import 'dart:developer';
|
|||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
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/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/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_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
|
||||||
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
||||||
final BlogPostRepository blogPostRepository;
|
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<String> 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<bool> createBlogPost({
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<XFile> 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<bool> createBlogPost({String? title, String? videoUrl, String? description, XFile? image}) async {
|
|
||||||
try {
|
try {
|
||||||
emit(const BlogPostCreateState.loading());
|
emit(
|
||||||
await blogPostRepository.createBlogPost(title, videoUrl, description, image);
|
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
|
||||||
emit(const BlogPostCreateState.loaded());
|
);
|
||||||
|
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;
|
return true;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
||||||
await reportFrom(error, stackTrace, this);
|
await reportFrom(error, stackTrace, this);
|
||||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
emit(BlogPostCreateState(errorMessage: error.toString()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> updateBlogPost({required int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl}) async {
|
Future<bool> updateBlogPost({
|
||||||
|
required int id,
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<int> existingMediaIds = const [],
|
||||||
|
List<XFile> newImages = const [],
|
||||||
|
List<int> 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 {
|
try {
|
||||||
emit(const BlogPostCreateState.loading());
|
emit(
|
||||||
await blogPostRepository.updateBlogPost(id, title, videoUrl, description, image, oldImageUrl);
|
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
|
||||||
emit(const BlogPostCreateState.loaded());
|
);
|
||||||
|
await blogPostRepository.updateBlogPost(
|
||||||
|
id: id,
|
||||||
|
title: trimmedTitle,
|
||||||
|
videoUrl: trimmedVideoUrl,
|
||||||
|
description: trimmedDescription,
|
||||||
|
existingMediaIds: existingMediaIds,
|
||||||
|
newImages: newImages,
|
||||||
|
mediaIdsToDelete: mediaIdsToDelete,
|
||||||
|
);
|
||||||
|
emit(const BlogPostCreateState(isSuccess: true));
|
||||||
return true;
|
return true;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
||||||
await reportFrom(error, stackTrace, this);
|
await reportFrom(error, stackTrace, this);
|
||||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
emit(BlogPostCreateState(errorMessage: error.toString()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,23 @@ part 'blog_post_create_state.freezed.dart';
|
|||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class BlogPostCreateState with _$BlogPostCreateState {
|
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;
|
bool get hasValidationErrors =>
|
||||||
|
titleError != null ||
|
||||||
const factory BlogPostCreateState.error({
|
descriptionError != null ||
|
||||||
required String errorMessage,
|
videoUrlError != null ||
|
||||||
}) = BlogPostCreateStateError;
|
imagesError != null ||
|
||||||
|
formError != null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:eatvio_app/app_keys.dart';
|
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/app_router.dart';
|
||||||
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
||||||
import 'package:eatvio_app/core/theme/app_decoration.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/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.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/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/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/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:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -36,20 +41,31 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
final blogPostCubit = sl<BlogPostCreateCubit>();
|
final blogPostCubit = sl<BlogPostCreateCubit>();
|
||||||
bool shrinkImage = false;
|
bool shrinkImage = false;
|
||||||
late StreamSubscription<bool> keyboardSubscription;
|
late StreamSubscription<bool> keyboardSubscription;
|
||||||
XFile? image;
|
final List<FeedGalleryImage> savedImages = [];
|
||||||
|
final List<XFile> pendingImages = [];
|
||||||
|
final List<int> mediaIdsToDelete = [];
|
||||||
final titleController = TextEditingController();
|
final titleController = TextEditingController();
|
||||||
final descriptionController = TextEditingController();
|
final descriptionController = TextEditingController();
|
||||||
final videoUrlController = TextEditingController();
|
final videoUrlController = TextEditingController();
|
||||||
bool isLoading = false;
|
|
||||||
bool enableMdPanel = false;
|
bool enableMdPanel = false;
|
||||||
bool isPreviewMode = false;
|
bool isPreviewMode = false;
|
||||||
final FocusNode descriptionFocusNode = FocusNode();
|
final FocusNode descriptionFocusNode = FocusNode();
|
||||||
|
|
||||||
|
bool get _isEditMode => widget.feedBlogPostModel != null;
|
||||||
|
|
||||||
|
int get _galleryCount => savedImages.length + pendingImages.length;
|
||||||
|
|
||||||
|
int get _remainingSlots =>
|
||||||
|
BlogPostRepository.maxGalleryImages - _galleryCount;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
blogPostCubit.resetFormErrors();
|
||||||
var keyboardVisibilityController = KeyboardVisibilityController();
|
var keyboardVisibilityController = KeyboardVisibilityController();
|
||||||
keyboardSubscription = keyboardVisibilityController.onChange.listen((bool visible) {
|
keyboardSubscription = keyboardVisibilityController.onChange.listen((
|
||||||
|
bool visible,
|
||||||
|
) {
|
||||||
setState(() {
|
setState(() {
|
||||||
shrinkImage = visible;
|
shrinkImage = visible;
|
||||||
enableMdPanel = visible;
|
enableMdPanel = visible;
|
||||||
@@ -59,6 +75,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
titleController.text = widget.feedBlogPostModel!.title;
|
titleController.text = widget.feedBlogPostModel!.title;
|
||||||
descriptionController.text = widget.feedBlogPostModel!.description;
|
descriptionController.text = widget.feedBlogPostModel!.description;
|
||||||
videoUrlController.text = widget.feedBlogPostModel!.videoUrl;
|
videoUrlController.text = widget.feedBlogPostModel!.videoUrl;
|
||||||
|
savedImages.addAll(
|
||||||
|
widget.feedBlogPostModel!.images.where((image) => image.id != null),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +85,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
keyboardSubscription.cancel();
|
keyboardSubscription.cancel();
|
||||||
descriptionFocusNode.dispose();
|
descriptionFocusNode.dispose();
|
||||||
|
titleController.dispose();
|
||||||
|
descriptionController.dispose();
|
||||||
|
videoUrlController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +100,16 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
return selectedText.startsWith(prefix) && selectedText.endsWith(suffix);
|
return selectedText.startsWith(prefix) && selectedText.endsWith(suffix);
|
||||||
} else {
|
} else {
|
||||||
final cursorPosition = selection.baseOffset;
|
final cursorPosition = selection.baseOffset;
|
||||||
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
if (cursorPosition >= prefix.length &&
|
||||||
final beforeCursor = text.substring(cursorPosition - prefix.length, cursorPosition);
|
text.length >= cursorPosition + suffix.length) {
|
||||||
final afterCursor = text.substring(cursorPosition, cursorPosition + suffix.length);
|
final beforeCursor = text.substring(
|
||||||
|
cursorPosition - prefix.length,
|
||||||
|
cursorPosition,
|
||||||
|
);
|
||||||
|
final afterCursor = text.substring(
|
||||||
|
cursorPosition,
|
||||||
|
cursorPosition + suffix.length,
|
||||||
|
);
|
||||||
return beforeCursor == prefix && afterCursor == suffix;
|
return beforeCursor == prefix && afterCursor == suffix;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,23 +122,36 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
|
|
||||||
if (selection.isValid && !selection.isCollapsed) {
|
if (selection.isValid && !selection.isCollapsed) {
|
||||||
final selectedText = text.substring(selection.start, selection.end);
|
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;
|
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;
|
descriptionController.selection = newSelection;
|
||||||
} else {
|
} else {
|
||||||
final cursorPosition = selection.baseOffset;
|
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 beforeCursor = text.substring(0, cursorPosition - prefix.length);
|
||||||
final afterCursor = text.substring(cursorPosition + suffix.length);
|
final afterCursor = text.substring(cursorPosition + suffix.length);
|
||||||
final newText = beforeCursor + afterCursor;
|
final newText = beforeCursor + afterCursor;
|
||||||
descriptionController.text = newText;
|
descriptionController.text = newText;
|
||||||
|
|
||||||
final newCursorPosition = cursorPosition - prefix.length;
|
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<BlogPostCreateScreen> {
|
|||||||
final selectedText = text.substring(selection.start, selection.end);
|
final selectedText = text.substring(selection.start, selection.end);
|
||||||
final formattedText = '$prefix$selectedText$suffix';
|
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;
|
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;
|
descriptionController.selection = newSelection;
|
||||||
} else {
|
} else {
|
||||||
final cursorPosition = selection.baseOffset;
|
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;
|
descriptionController.text = newText;
|
||||||
|
|
||||||
final newCursorPosition = cursorPosition + prefix.length;
|
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<BlogPostCreateScreen> {
|
|||||||
if (linkRegex.hasMatch(selectedText)) {
|
if (linkRegex.hasMatch(selectedText)) {
|
||||||
final match = linkRegex.firstMatch(selectedText);
|
final match = linkRegex.firstMatch(selectedText);
|
||||||
final linkText = match?.group(1) ?? 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;
|
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;
|
descriptionController.selection = newSelection;
|
||||||
} else {
|
} else {
|
||||||
final formattedText = '[$selectedText](url)';
|
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;
|
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;
|
descriptionController.selection = newSelection;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -209,11 +275,14 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
void _applyHorizontalRule() {
|
void _applyHorizontalRule() {
|
||||||
final cursorPosition = descriptionController.selection.baseOffset;
|
final cursorPosition = descriptionController.selection.baseOffset;
|
||||||
final text = descriptionController.text;
|
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;
|
descriptionController.text = newText;
|
||||||
|
|
||||||
final newCursorPosition = cursorPosition + 5;
|
final newCursorPosition = cursorPosition + 5;
|
||||||
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
|
descriptionController.selection = TextSelection.collapsed(
|
||||||
|
offset: newCursorPosition,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _togglePreview() {
|
void _togglePreview() {
|
||||||
@@ -240,7 +309,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
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<BlogPostCreateScreen> {
|
|||||||
if (text.isEmpty) {
|
if (text.isEmpty) {
|
||||||
return const Text(
|
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<BlogPostCreateScreen> {
|
|||||||
widgets.add(
|
widgets.add(
|
||||||
Text(
|
Text(
|
||||||
line.substring(2),
|
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('## ')) {
|
} else if (line.startsWith('## ')) {
|
||||||
widgets.add(
|
widgets.add(
|
||||||
Text(
|
Text(
|
||||||
line.substring(3),
|
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('### ')) {
|
} else if (line.startsWith('### ')) {
|
||||||
widgets.add(
|
widgets.add(
|
||||||
Text(
|
Text(
|
||||||
line.substring(4),
|
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('* ')) {
|
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||||
@@ -307,7 +394,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('$number. ', style: const TextStyle(fontSize: 16)),
|
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<BlogPostCreateScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets);
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: widgets,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _parseInlineMarkdown(String text) {
|
Widget _parseInlineMarkdown(String text) {
|
||||||
@@ -347,14 +441,22 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
spans.add(
|
spans.add(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: matchedText.substring(2, matchedText.length - 2),
|
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('*')) {
|
} else if (matchedText.startsWith('*') && matchedText.endsWith('*')) {
|
||||||
spans.add(
|
spans.add(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: matchedText.substring(1, matchedText.length - 1),
|
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('](')) {
|
} else if (matchedText.startsWith('[') && matchedText.contains('](')) {
|
||||||
@@ -364,7 +466,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
spans.add(
|
spans.add(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: linkText,
|
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<BlogPostCreateScreen> {
|
|||||||
return BlocProvider<BlogPostCreateCubit>(
|
return BlocProvider<BlogPostCreateCubit>(
|
||||||
create: (BuildContext context) => blogPostCubit,
|
create: (BuildContext context) => blogPostCubit,
|
||||||
child: BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
child: BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
|
bloc: blogPostCubit,
|
||||||
listener: (BuildContext context, BlogPostCreateState state) {
|
listener: (BuildContext context, BlogPostCreateState state) {
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
if (state.isSuccess) {
|
||||||
context.read<FeedCubit>().loadFeedBlogPostList();
|
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||||
|
ProfileCubit.myProfileCubit?.reloadBlogPosts();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: _buildBlogPostCreate(context),
|
child: _buildBlogPostCreate(context),
|
||||||
@@ -401,6 +509,10 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showImagePicker() {
|
void _showImagePicker() {
|
||||||
|
if (_remainingSlots <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showCupertinoModalPopup<void>(
|
showCupertinoModalPopup<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
@@ -415,14 +527,27 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
description: s.allowAccessToCameraDescription,
|
description: s.allowAccessToCameraDescription,
|
||||||
onGranted: () async {
|
onGranted: () async {
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
image = await picker.pickImage(source: ImageSource.camera);
|
final image = await picker.pickImage(
|
||||||
setState(() {});
|
source: ImageSource.camera,
|
||||||
|
);
|
||||||
|
if (image != null && _remainingSlots > 0) {
|
||||||
|
setState(() {
|
||||||
|
pendingImages.add(image);
|
||||||
|
});
|
||||||
|
blogPostCubit.clearImagesError();
|
||||||
|
}
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
context: context,
|
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(
|
CupertinoActionSheetAction(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -432,30 +557,88 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
description: s.allowAcessToGaleryDescription,
|
description: s.allowAcessToGaleryDescription,
|
||||||
onGranted: () async {
|
onGranted: () async {
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
image = await picker.pickImage(source: ImageSource.gallery);
|
final images = await picker.pickMultiImage(
|
||||||
setState(() {});
|
limit: _remainingSlots,
|
||||||
|
);
|
||||||
|
if (images.isNotEmpty) {
|
||||||
|
setState(() {
|
||||||
|
pendingImages.addAll(images.take(_remainingSlots));
|
||||||
|
});
|
||||||
|
blogPostCubit.clearImagesError();
|
||||||
|
}
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
AppRouter.navigatorKey.currentContext?.pop();
|
AppRouter.navigatorKey.currentContext?.pop();
|
||||||
},
|
},
|
||||||
context: context,
|
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) {
|
Widget _mdButton(IconData icon, Function() onTap) {
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 36,
|
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),
|
child: Icon(icon),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -468,11 +651,18 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 36,
|
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: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
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<BlogPostCreateScreen> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
boxShadow: [
|
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(
|
child: Row(
|
||||||
@@ -508,9 +703,15 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_mdButton(Icons.format_italic, _applyItalic),
|
_mdButton(Icons.format_italic, _applyItalic),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
_mdButton(
|
||||||
|
Icons.format_list_numbered,
|
||||||
|
_applyNumberedList,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
_mdButton(
|
||||||
|
Icons.format_list_bulleted,
|
||||||
|
_applyBulletedList,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_mdButton(Icons.link, _applyLink),
|
_mdButton(Icons.link, _applyLink),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -528,157 +729,297 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
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,
|
: null,
|
||||||
appBar: AppBar(),
|
appBar: AppBar(),
|
||||||
body: SingleChildScrollView(
|
body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
padding: const EdgeInsets.all(16),
|
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<void> _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<int>.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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 16),
|
Icon(Icons.photo_library, color: appTheme.blueGray300),
|
||||||
const Text('Заголовок'),
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 8),
|
Text(
|
||||||
CustomTextFormField(key: AppKeys.blogPostCreateScreenTitleTextField, controller: titleController),
|
'Добавить',
|
||||||
const SizedBox(height: 16),
|
style: TextStyle(fontSize: 12, color: appTheme.blueGray300),
|
||||||
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),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _createBlogPostButton() {
|
Widget _buildGalleryTile({
|
||||||
return InkWell(
|
required int index,
|
||||||
onTap: isLoading
|
required double height,
|
||||||
? null
|
required ImageProvider imageProvider,
|
||||||
: () async {
|
required VoidCallback onRemove,
|
||||||
setState(() {
|
}) {
|
||||||
isLoading = true;
|
return ReorderableDelayedDragStartListener(
|
||||||
});
|
index: index,
|
||||||
if (widget.feedBlogPostModel != null) {
|
child: Container(
|
||||||
final result = await blogPostCubit.updateBlogPost(
|
width: height,
|
||||||
id: widget.feedBlogPostModel!.id,
|
height: height,
|
||||||
title: titleController.text,
|
margin: const EdgeInsets.only(right: 8),
|
||||||
description: descriptionController.text,
|
child: Stack(
|
||||||
videoUrl: videoUrlController.text,
|
clipBehavior: Clip.none,
|
||||||
image: image,
|
children: [
|
||||||
oldImageUrl: widget.feedBlogPostModel!.image,
|
Positioned.fill(
|
||||||
);
|
|
||||||
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,
|
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(16),
|
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)
|
Positioned(
|
||||||
SizedBox(
|
top: -6,
|
||||||
width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32,
|
right: -6,
|
||||||
child: ClipRRect(
|
child: Material(
|
||||||
borderRadius: BorderRadius.circular(16),
|
color: Colors.white,
|
||||||
child: Image(image: NetworkImage('${sl<AppConfig>().baseUrl}/storage/${widget.feedBlogPostModel!.image}')),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +1,122 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
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/core/image_compressing/image_compress_utility.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.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:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
|
|
||||||
class BlogPostRepository {
|
class BlogPostRepository {
|
||||||
|
static const maxGalleryImages = 10;
|
||||||
|
|
||||||
final BlogPostService blogPostService;
|
final BlogPostService blogPostService;
|
||||||
|
|
||||||
BlogPostRepository({
|
BlogPostRepository({required this.blogPostService});
|
||||||
required this.blogPostService,
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<void> createBlogPost(
|
Future<void> createBlogPost({
|
||||||
String? title,
|
String? title,
|
||||||
String? videoUrl,
|
String? videoUrl,
|
||||||
String? description,
|
String? description,
|
||||||
XFile? image,
|
List<XFile> images = const [],
|
||||||
) async {
|
}) async {
|
||||||
final formData = FormData.fromMap({
|
final formData = await _buildFormData(
|
||||||
if (title != null && title.isNotEmpty) 'title': title,
|
title: title,
|
||||||
if (description != null && description.isNotEmpty) 'description_md': description,
|
videoUrl: videoUrl,
|
||||||
if (videoUrl != null && videoUrl.isNotEmpty) 'video_url': videoUrl,
|
description: description,
|
||||||
if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name),
|
newImages: images,
|
||||||
});
|
|
||||||
|
|
||||||
return blogPostService.createBlogPost(
|
|
||||||
formData: formData,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return blogPostService.createBlogPost(formData: formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<XFile?> downloadImage(String imageUrl) async {
|
Future<void> updateBlogPost({
|
||||||
final response = await http.get(Uri.parse('${sl<AppConfig>().baseUrl}/storage/$imageUrl'));
|
required int id,
|
||||||
|
String? title,
|
||||||
if (response.statusCode == 200) {
|
String? videoUrl,
|
||||||
final directory = await getTemporaryDirectory();
|
String? description,
|
||||||
final filePath = '${directory.path}/image.jpg';
|
List<int> existingMediaIds = const [],
|
||||||
final file = File(filePath);
|
List<XFile> newImages = const [],
|
||||||
await file.writeAsBytes(response.bodyBytes);
|
List<int> mediaIdsToDelete = const [],
|
||||||
|
}) async {
|
||||||
return XFile(file.path);
|
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<void> updateBlogPost(
|
Future<void> deleteBlogPostMedia({
|
||||||
int id,
|
required int postId,
|
||||||
String? title,
|
required int mediaId,
|
||||||
String? videoUrl,
|
}) async {
|
||||||
String? description,
|
await blogPostService.deleteBlogPostMedia(postId: postId, mediaId: mediaId);
|
||||||
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<void> deleteBlogPost(int id) async {
|
Future<void> deleteBlogPost(int id) async {
|
||||||
await blogPostService.deleteBlogPost(id: id);
|
await blogPostService.deleteBlogPost(id: id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<FormData> _buildFormData({
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<int> existingMediaIds = const [],
|
||||||
|
List<XFile> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,21 +7,23 @@ part 'blog_post_service.g.dart';
|
|||||||
abstract class BlogPostService {
|
abstract class BlogPostService {
|
||||||
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
|
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
|
||||||
|
|
||||||
@POST('/api/blogPosts/create/published')
|
@POST('/api/v2/blogPosts/create/published')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<void> createBlogPost({
|
Future<void> createBlogPost({@Body() required FormData formData});
|
||||||
@Body() required FormData formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
@POST('/api/blogPosts/update/published/{id}')
|
@PUT('/api/v2/blogPosts/update/published/{id}')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<void> updateBlogPost({
|
Future<void> updateBlogPost({
|
||||||
@Path('id') required int id,
|
@Path('id') required int id,
|
||||||
@Body() required FormData formData,
|
@Body() required FormData formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@DELETE('/api/blogPosts/delete/{id}')
|
@DELETE('/api/v2/blogPosts/{postId}/media/{mediaId}')
|
||||||
Future<void> deleteBlogPost({
|
Future<void> deleteBlogPostMedia({
|
||||||
@Path('id') required int id,
|
@Path('postId') required int postId,
|
||||||
|
@Path('mediaId') required int mediaId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@DELETE('/api/blogPosts/delete/{id}')
|
||||||
|
Future<void> deleteBlogPost({@Path('id') required int id});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String> 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<String> allowedRootHosts,
|
||||||
|
) {
|
||||||
|
for (final allowed in allowedRootHosts) {
|
||||||
|
if (host == allowed || host.endsWith('.$allowed')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,11 +14,15 @@ class PhotoViewScreen extends StatefulWidget {
|
|||||||
final int index;
|
final int index;
|
||||||
final bool isLocal;
|
final bool isLocal;
|
||||||
|
|
||||||
|
/// When set, used instead of the image URL so source/destination tags stay unique.
|
||||||
|
final Object? heroTag;
|
||||||
|
|
||||||
const PhotoViewScreen({
|
const PhotoViewScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.imageUrls,
|
required this.imageUrls,
|
||||||
required this.index,
|
required this.index,
|
||||||
this.isLocal = false,
|
this.isLocal = false,
|
||||||
|
this.heroTag,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -36,6 +40,23 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
super.initState();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DismissiblePage(
|
return DismissiblePage(
|
||||||
@@ -44,23 +65,19 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
direction: DismissiblePageDismissDirection.vertical,
|
direction: DismissiblePageDismissDirection.vertical,
|
||||||
isFullScreen: true,
|
isFullScreen: true,
|
||||||
child: PhotoViewGallery.builder(
|
child: PhotoViewGallery.builder(
|
||||||
backgroundDecoration: const BoxDecoration(
|
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||||
color: Colors.transparent,
|
|
||||||
),
|
|
||||||
scrollPhysics: const BouncingScrollPhysics(),
|
scrollPhysics: const BouncingScrollPhysics(),
|
||||||
builder: (BuildContext context, int index) {
|
builder: (BuildContext context, int index) {
|
||||||
final imageProvider = widget.isLocal
|
final imageProvider = widget.isLocal
|
||||||
? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
|
? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
|
||||||
: CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider<Object>;
|
: CachedNetworkImageProvider(widget.imageUrls[index])
|
||||||
|
as ImageProvider<Object>;
|
||||||
|
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
imageProvider: imageProvider,
|
imageProvider: imageProvider,
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
minScale: PhotoViewComputedScale.contained,
|
minScale: PhotoViewComputedScale.contained,
|
||||||
heroAttributes: PhotoViewHeroAttributes(
|
heroAttributes: _heroAttributesFor(index),
|
||||||
tag: widget.imageUrls[index],
|
|
||||||
transitionOnUserGestures: true,
|
|
||||||
),
|
|
||||||
errorBuilder: (context, object, stackTrace) {
|
errorBuilder: (context, object, stackTrace) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
@@ -76,8 +93,12 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.all(16),
|
contentPadding: const EdgeInsets.all(16),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) {
|
if (!await launchUrl(
|
||||||
throw Exception('Could not launch ${widget.imageUrls[index]}');
|
Uri.parse(widget.imageUrls[index]),
|
||||||
|
)) {
|
||||||
|
throw Exception(
|
||||||
|
'Could not launch ${widget.imageUrls[index]}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
leading: const Icon(
|
leading: const Icon(
|
||||||
@@ -89,7 +110,10 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
widget.imageUrls[index].replaceAll('${sl<AppConfig>().baseUrl}/storage/chat_files/', ''),
|
widget.imageUrls[index].replaceAll(
|
||||||
|
'${sl<AppConfig>().baseUrl}/storage/chat_files/',
|
||||||
|
'',
|
||||||
|
),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -109,7 +133,9 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
width: 20.0,
|
width: 20.0,
|
||||||
height: 20.0,
|
height: 20.0,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
value: event == null
|
||||||
|
? 0
|
||||||
|
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
part 'consultant_profile_model.freezed.dart';
|
part 'consultant_profile_model.freezed.dart';
|
||||||
@@ -54,37 +55,7 @@ abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
|||||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
|
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
|
||||||
_$ConsultantProfileModelFromJson(json);
|
_$ConsultantProfileModelFromJson(json);
|
||||||
|
|
||||||
bool get hasValidPassportData {
|
bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty;
|
||||||
final last = lastName ?? '';
|
|
||||||
final first = firstName ?? '';
|
|
||||||
final innValue = inn ?? '';
|
|
||||||
final snilsValue = snils ?? '';
|
|
||||||
final series = passportSeries ?? '';
|
|
||||||
final number = passportNumber ?? '';
|
|
||||||
final issueDate = passportIssueDate ?? '';
|
|
||||||
final issuer = passportIssuer ?? '';
|
|
||||||
final issuerCode = passportIssuerCode ?? '';
|
|
||||||
final birth = birthDate ?? '';
|
|
||||||
final birthPlaceValue = birthPlace ?? '';
|
|
||||||
final registration = registrationAddress ?? '';
|
|
||||||
final residence = residenceAddress ?? '';
|
|
||||||
final citizenshipValue = citizenship ?? '';
|
|
||||||
|
|
||||||
return last.trim().isNotEmpty &&
|
|
||||||
first.trim().isNotEmpty &&
|
|
||||||
RegExp(r'^\d{12}$').hasMatch(innValue) &&
|
|
||||||
RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) &&
|
|
||||||
RegExp(r'^\d{4}$').hasMatch(series) &&
|
|
||||||
RegExp(r'^\d{6}$').hasMatch(number) &&
|
|
||||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) &&
|
|
||||||
issuer.trim().isNotEmpty &&
|
|
||||||
RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) &&
|
|
||||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) &&
|
|
||||||
birthPlaceValue.trim().isNotEmpty &&
|
|
||||||
registration.trim().isNotEmpty &&
|
|
||||||
residence.trim().isNotEmpty &&
|
|
||||||
RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank
|
|||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||||
@@ -240,6 +241,9 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
bool? acceptOffer,
|
bool? acceptOffer,
|
||||||
bool? acceptFrdoAgreement,
|
bool? acceptFrdoAgreement,
|
||||||
]) {
|
]) {
|
||||||
|
final nextFieldErrors = state.fieldErrors.isEmpty
|
||||||
|
? const <String, String>{}
|
||||||
|
: PassportDataValidator.validate(consultant);
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
consultant: consultant,
|
consultant: consultant,
|
||||||
@@ -247,11 +251,17 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
fieldErrors: const <String, String>{},
|
fieldErrors: nextFieldErrors,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, String> validatePassportFields() {
|
||||||
|
final errors = PassportDataValidator.validate(state.consultant);
|
||||||
|
emit(state.copyWith(fieldErrors: errors, error: null));
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) {
|
void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) {
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -322,8 +332,8 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerConsultant() async {
|
Future<void> registerConsultant() async {
|
||||||
if (!state.consultant.hasValidPassportData) {
|
final passportErrors = validatePassportFields();
|
||||||
emit(state.copyWith(error: 'Заполните паспортные данные'));
|
if (passportErrors.isNotEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,6 +411,10 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
|
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final passportErrors = validatePassportFields();
|
||||||
|
if (passportErrors.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
scoringRetryInProgress: true,
|
scoringRetryInProgress: true,
|
||||||
@@ -413,18 +427,6 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
);
|
);
|
||||||
_update = true;
|
_update = true;
|
||||||
try {
|
try {
|
||||||
if (!state.consultant.hasValidPassportData) {
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
scoringRetryInProgress: false,
|
|
||||||
isScoringPolling: false,
|
|
||||||
awaitingScoringOutcome: false,
|
|
||||||
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
|
||||||
error: 'Заполните паспортные данные',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await consultantRepository.registerConsultant(
|
await consultantRepository.registerConsultant(
|
||||||
consultant: buildSubmitPayload(),
|
consultant: buildSubmitPayload(),
|
||||||
update: true,
|
update: true,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl
|
|||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||||
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||||
@@ -332,6 +333,7 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
|||||||
controller: innController,
|
controller: innController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
maxLength: 12,
|
maxLength: 12,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
hasError: errors.containsKey('inn'),
|
hasError: errors.containsKey('inn'),
|
||||||
onChanged: (_) => _syncToCubit(),
|
onChanged: (_) => _syncToCubit(),
|
||||||
),
|
),
|
||||||
@@ -446,6 +448,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
|||||||
controller: seriesController,
|
controller: seriesController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
maxLength: 4,
|
maxLength: 4,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
],
|
||||||
hasError: errors.containsKey('passport_series'),
|
hasError: errors.containsKey('passport_series'),
|
||||||
onChanged: (_) => _syncToCubit(),
|
onChanged: (_) => _syncToCubit(),
|
||||||
),
|
),
|
||||||
@@ -460,6 +465,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
|||||||
controller: numberController,
|
controller: numberController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
maxLength: 6,
|
maxLength: 6,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
],
|
||||||
hasError: errors.containsKey('passport_number'),
|
hasError: errors.containsKey('passport_number'),
|
||||||
onChanged: (_) => _syncToCubit(),
|
onChanged: (_) => _syncToCubit(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -146,212 +146,230 @@ class _StepperBecomeConsultantScreenState
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<RegisterConsultantCubit>(
|
return BlocProvider<RegisterConsultantCubit>(
|
||||||
create: (context) => registrationConsultantCubit,
|
create: (context) => registrationConsultantCubit,
|
||||||
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
child: BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
listenWhen: (previous, current) =>
|
listenWhen: (previous, current) =>
|
||||||
previous.awaitingScoringOutcome &&
|
previous.fieldErrors.isEmpty &&
|
||||||
!current.awaitingScoringOutcome &&
|
current.fieldErrors.isNotEmpty &&
|
||||||
current.alfaScoringStatus.isComplete,
|
current.error == null,
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
_showSuccessSheet(context);
|
pageController.jumpToPage(5);
|
||||||
},
|
},
|
||||||
builder: (context, state) {
|
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
if (state.loading) {
|
listenWhen: (previous, current) =>
|
||||||
return const Scaffold(body: Loader());
|
previous.awaitingScoringOutcome &&
|
||||||
}
|
!current.awaitingScoringOutcome &&
|
||||||
if (state.error != null &&
|
current.alfaScoringStatus.isComplete,
|
||||||
state.requestModel == null &&
|
listener: (context, state) {
|
||||||
widget.slug != null &&
|
_showSuccessSheet(context);
|
||||||
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
},
|
||||||
return Scaffold(
|
builder: (context, state) {
|
||||||
appBar: AppBar(
|
if (state.loading) {
|
||||||
centerTitle: true,
|
return const Scaffold(body: Loader());
|
||||||
leading: BackButton(
|
}
|
||||||
onPressed: () {
|
if (state.error != null &&
|
||||||
if (context.canPop()) {
|
state.requestModel == null &&
|
||||||
GoRouter.of(context).pop();
|
widget.slug != null &&
|
||||||
return;
|
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||||
}
|
return Scaffold(
|
||||||
GoRouter.of(context).go('/feed-screen');
|
appBar: AppBar(
|
||||||
},
|
centerTitle: true,
|
||||||
),
|
leading: BackButton(
|
||||||
title: Text(
|
onPressed: () {
|
||||||
'',
|
if (context.canPop()) {
|
||||||
maxLines: 2,
|
GoRouter.of(context).pop();
|
||||||
textAlign: TextAlign.center,
|
return;
|
||||||
overflow: TextOverflow.ellipsis,
|
}
|
||||||
softWrap: true,
|
GoRouter.of(context).go('/feed-screen');
|
||||||
style: TextStyles.headingXLSemibold.copyWith(
|
|
||||||
color: appTheme.black,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: DataErrorWidget(
|
|
||||||
onReload: () => context
|
|
||||||
.read<RegisterConsultantCubit>()
|
|
||||||
.reloadData(widget.slug),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressValue == 0) {
|
|
||||||
progressValue = 1 / totalSteps;
|
|
||||||
}
|
|
||||||
|
|
||||||
final submitDisabled =
|
|
||||||
isLoading ||
|
|
||||||
state.isScoringPolling ||
|
|
||||||
state.scoringRetryInProgress;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
floatingActionButtonLocation:
|
|
||||||
FloatingActionButtonLocation.centerFloat,
|
|
||||||
floatingActionButton: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
height: 48,
|
|
||||||
width: MediaQuery.of(context).size.width * 0.2,
|
|
||||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
|
||||||
child: FloatingActionButton.extended(
|
|
||||||
heroTag: 'backButtonRegistration',
|
|
||||||
highlightElevation: 0,
|
|
||||||
focusElevation: 0,
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
backgroundColor: const Color(0xFFEAF2FD),
|
|
||||||
onPressed: () async {
|
|
||||||
setState(() {
|
|
||||||
pageController.previousPage(
|
|
||||||
duration: const Duration(milliseconds: 350),
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
label: Icon(
|
),
|
||||||
Icons.arrow_back_ios_new_rounded,
|
title: Text(
|
||||||
color: appTheme.blueA400,
|
'',
|
||||||
|
maxLines: 2,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
softWrap: true,
|
||||||
|
style: TextStyles.headingXLSemibold.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.isCanceled == true && step != totalSteps)
|
body: DataErrorWidget(
|
||||||
InkWell(
|
onReload: () => context
|
||||||
onTap: () {
|
.read<RegisterConsultantCubit>()
|
||||||
final canceledModeration =
|
.reloadData(widget.slug),
|
||||||
state.requestModel?.canceledModeration;
|
),
|
||||||
if (canceledModeration == null) return;
|
);
|
||||||
showDialog(
|
}
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
if (progressValue == 0) {
|
||||||
title: Text(canceledModeration.title),
|
progressValue = 1 / totalSteps;
|
||||||
content: Column(
|
}
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
final submitDisabled =
|
||||||
children: [
|
isLoading ||
|
||||||
if (canceledModeration.additional != null)
|
state.isScoringPolling ||
|
||||||
for (final item
|
state.scoringRetryInProgress;
|
||||||
in canceledModeration.additional!) ...[
|
|
||||||
Text('• $item'),
|
return Scaffold(
|
||||||
],
|
floatingActionButtonLocation:
|
||||||
|
FloatingActionButtonLocation.centerFloat,
|
||||||
|
floatingActionButton: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 48,
|
||||||
|
width: MediaQuery.of(context).size.width * 0.2,
|
||||||
|
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||||
|
child: FloatingActionButton.extended(
|
||||||
|
heroTag: 'backButtonRegistration',
|
||||||
|
highlightElevation: 0,
|
||||||
|
focusElevation: 0,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
backgroundColor: const Color(0xFFEAF2FD),
|
||||||
|
onPressed: () async {
|
||||||
|
setState(() {
|
||||||
|
pageController.previousPage(
|
||||||
|
duration: const Duration(milliseconds: 350),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
label: Icon(
|
||||||
|
Icons.arrow_back_ios_new_rounded,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.isCanceled == true && step != totalSteps)
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
final canceledModeration =
|
||||||
|
state.requestModel?.canceledModeration;
|
||||||
|
if (canceledModeration == null) return;
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(canceledModeration.title),
|
||||||
|
content: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (canceledModeration.additional != null)
|
||||||
|
for (final item
|
||||||
|
in canceledModeration.additional!) ...[
|
||||||
|
Text('• $item'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
);
|
||||||
TextButton(
|
},
|
||||||
onPressed: () => Navigator.pop(context),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: const Text('OK'),
|
child: Ink(
|
||||||
),
|
height: 48,
|
||||||
],
|
width: MediaQuery.of(context).size.width * 0.6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
color: const Color(0x33FFCC00),
|
||||||
),
|
),
|
||||||
);
|
child: const Center(
|
||||||
},
|
child: Text(
|
||||||
borderRadius: BorderRadius.circular(20),
|
'Комментарий',
|
||||||
child: Ink(
|
style: TextStyle(
|
||||||
height: 48,
|
fontSize: 18,
|
||||||
width: MediaQuery.of(context).size.width * 0.6,
|
fontWeight: FontWeight.w600,
|
||||||
decoration: BoxDecoration(
|
color: Color(0xFF323F4B),
|
||||||
borderRadius: BorderRadius.circular(20),
|
height: 1,
|
||||||
color: const Color(0x33FFCC00),
|
),
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Text(
|
|
||||||
'Комментарий',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Color(0xFF323F4B),
|
|
||||||
height: 1,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Container(
|
||||||
Container(
|
height: 48,
|
||||||
height: 48,
|
width: widget.isCanceled == true && step != totalSteps
|
||||||
width: widget.isCanceled == true && step != totalSteps
|
? MediaQuery.of(context).size.width * 0.2
|
||||||
? MediaQuery.of(context).size.width * 0.2
|
: MediaQuery.of(context).size.width * 0.8,
|
||||||
: MediaQuery.of(context).size.width * 0.8,
|
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
child: FloatingActionButton.extended(
|
||||||
child: FloatingActionButton.extended(
|
heroTag: 'nextButtonRegistration',
|
||||||
heroTag: 'nextButtonRegistration',
|
highlightElevation: 0,
|
||||||
highlightElevation: 0,
|
elevation: 0,
|
||||||
elevation: 0,
|
focusElevation: 0,
|
||||||
focusElevation: 0,
|
shape: RoundedRectangleBorder(
|
||||||
shape: RoundedRectangleBorder(
|
borderRadius: BorderRadius.circular(20),
|
||||||
borderRadius: BorderRadius.circular(20),
|
),
|
||||||
),
|
backgroundColor: submitDisabled
|
||||||
backgroundColor: submitDisabled
|
? Colors.grey
|
||||||
? Colors.grey
|
: widget.isCanceled == true && step != totalSteps
|
||||||
: widget.isCanceled == true && step != totalSteps
|
? const Color(0xFFEAF2FD)
|
||||||
? const Color(0xFFEAF2FD)
|
: appTheme.blueA400,
|
||||||
: appTheme.blueA400,
|
onPressed: submitDisabled
|
||||||
onPressed: submitDisabled
|
? null
|
||||||
? null
|
: () async {
|
||||||
: () async {
|
if (step == totalSteps) {
|
||||||
if (step == totalSteps) {
|
final passportErrors =
|
||||||
if (!state.consultant.hasValidPassportData) {
|
registrationConsultantCubit
|
||||||
showDialog(
|
.validatePassportFields();
|
||||||
context: context,
|
if (passportErrors.isNotEmpty) {
|
||||||
builder: (context) => AlertDialog(
|
pageController.jumpToPage(5);
|
||||||
actionsPadding: const EdgeInsets.symmetric(
|
return;
|
||||||
horizontal: 4,
|
}
|
||||||
),
|
if (registrationConsultantCubit
|
||||||
content: const Text(
|
.hasAcceptedRequiredAgreements) {
|
||||||
'Заполните паспортные данные',
|
setState(() {
|
||||||
),
|
isLoading = true;
|
||||||
actions: [
|
});
|
||||||
TextButton(
|
try {
|
||||||
child: const Text('Ок'),
|
await registrationConsultantCubit
|
||||||
onPressed: () =>
|
.registerConsultant();
|
||||||
Navigator.of(context).pop(),
|
if (!context.mounted) return;
|
||||||
),
|
if (registrationConsultantCubit
|
||||||
],
|
.state
|
||||||
),
|
.error !=
|
||||||
);
|
null) {
|
||||||
return;
|
showDialog(
|
||||||
}
|
context: context,
|
||||||
if (registrationConsultantCubit
|
builder: (context) => AlertDialog(
|
||||||
.hasAcceptedRequiredAgreements) {
|
actionsPadding:
|
||||||
setState(() {
|
const EdgeInsets.symmetric(
|
||||||
isLoading = true;
|
horizontal: 4,
|
||||||
});
|
),
|
||||||
try {
|
content: Text(
|
||||||
await registrationConsultantCubit
|
registrationConsultantCubit
|
||||||
.registerConsultant();
|
.state
|
||||||
if (!context.mounted) return;
|
.error!,
|
||||||
if (registrationConsultantCubit.state.error !=
|
),
|
||||||
null) {
|
actions: [
|
||||||
showDialog(
|
TextButton(
|
||||||
|
child: const Text('Ок'),
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Success sheet is shown by BlocListener when scoring completes.
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
actionsPadding:
|
actionsPadding:
|
||||||
const EdgeInsets.symmetric(
|
const EdgeInsets.symmetric(
|
||||||
horizontal: 4,
|
horizontal: 4,
|
||||||
),
|
),
|
||||||
content: Text(
|
content: Text(e.toString()),
|
||||||
registrationConsultantCubit
|
|
||||||
.state
|
|
||||||
.error!,
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Ок'),
|
child: const Text('Ок'),
|
||||||
@@ -361,140 +379,120 @@ class _StepperBecomeConsultantScreenState
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Success sheet is shown by BlocListener when scoring completes.
|
} else {
|
||||||
} catch (e) {
|
showDialog(
|
||||||
if (!context.mounted) return;
|
|
||||||
showDialog<void>(
|
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
actionsPadding:
|
actionsPadding:
|
||||||
const EdgeInsets.symmetric(
|
const EdgeInsets.symmetric(
|
||||||
horizontal: 4,
|
horizontal: 4,
|
||||||
),
|
),
|
||||||
content: Text(e.toString()),
|
content: Text(
|
||||||
|
state.isUpdating
|
||||||
|
? 'Примите договор публичной оферты'
|
||||||
|
: 'Примите оферту и согласие ФРДО',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Ок'),
|
child: const Text('Ок'),
|
||||||
onPressed: () =>
|
onPressed: () {
|
||||||
Navigator.of(context).pop(),
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
setState(() {
|
|
||||||
isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showDialog(
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
context: context,
|
setState(() {
|
||||||
builder: (context) => AlertDialog(
|
pageController.nextPage(
|
||||||
actionsPadding: const EdgeInsets.symmetric(
|
duration: const Duration(milliseconds: 350),
|
||||||
horizontal: 4,
|
curve: Curves.easeInOut,
|
||||||
),
|
);
|
||||||
content: Text(
|
});
|
||||||
state.isUpdating
|
|
||||||
? 'Примите договор публичной оферты'
|
|
||||||
: 'Примите оферту и согласие ФРДО',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
child: const Text('Ок'),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
label: submitDisabled && step == totalSteps
|
||||||
setState(() {
|
? const CircularProgressIndicator()
|
||||||
pageController.nextPage(
|
: widget.isCanceled == true && step != totalSteps
|
||||||
duration: const Duration(milliseconds: 350),
|
? Icon(
|
||||||
curve: Curves.easeInOut,
|
Icons.arrow_forward_ios_outlined,
|
||||||
);
|
color: appTheme.blueA400,
|
||||||
});
|
)
|
||||||
}
|
: Text(
|
||||||
},
|
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||||
label: submitDisabled && step == totalSteps
|
style: theme.textTheme.titleSmall!.copyWith(
|
||||||
? const CircularProgressIndicator()
|
color: Colors.white,
|
||||||
: widget.isCanceled == true && step != totalSteps
|
),
|
||||||
? Icon(
|
|
||||||
Icons.arrow_forward_ios_outlined,
|
|
||||||
color: appTheme.blueA400,
|
|
||||||
)
|
|
||||||
: Text(
|
|
||||||
step == totalSteps ? 'Отправить' : 'Продолжить',
|
|
||||||
style: theme.textTheme.titleSmall!.copyWith(
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
appBar: AppBar(
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
leading: Builder(
|
||||||
|
builder: (context) => IconButton(
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
onPressed: () => GoRouter.of(context).pop(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
centerTitle: true,
|
||||||
),
|
title: SvgPicture.asset(
|
||||||
appBar: AppBar(
|
ImageConstant.imgLogoEatvio,
|
||||||
automaticallyImplyLeading: false,
|
height: 48,
|
||||||
leading: Builder(
|
fit: BoxFit.contain,
|
||||||
builder: (context) => IconButton(
|
|
||||||
icon: const Icon(Icons.close_rounded),
|
|
||||||
onPressed: () => GoRouter.of(context).pop(),
|
|
||||||
),
|
),
|
||||||
),
|
actions: [
|
||||||
centerTitle: true,
|
Padding(
|
||||||
title: SvgPicture.asset(
|
padding: const EdgeInsets.only(right: 16),
|
||||||
ImageConstant.imgLogoEatvio,
|
child: Text('шаг $step из $totalSteps'),
|
||||||
height: 48,
|
),
|
||||||
fit: BoxFit.contain,
|
],
|
||||||
),
|
bottom: PreferredSize(
|
||||||
actions: [
|
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||||
Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(right: 16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text('шаг $step из $totalSteps'),
|
child: TweenAnimationBuilder<double>(
|
||||||
),
|
duration: const Duration(milliseconds: 350),
|
||||||
],
|
curve: Curves.easeInOut,
|
||||||
bottom: PreferredSize(
|
tween: Tween<double>(
|
||||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
begin: 1 / totalSteps,
|
||||||
child: Padding(
|
end: progressValue,
|
||||||
padding: const EdgeInsets.all(16),
|
),
|
||||||
child: TweenAnimationBuilder<double>(
|
builder: (context, value, _) => LinearProgressIndicator(
|
||||||
duration: const Duration(milliseconds: 350),
|
value: value,
|
||||||
curve: Curves.easeInOut,
|
borderRadius: BorderRadius.circular(16),
|
||||||
tween: Tween<double>(
|
minHeight: 8,
|
||||||
begin: 1 / totalSteps,
|
backgroundColor: Colors.grey.shade100,
|
||||||
end: progressValue,
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
),
|
appTheme.blueA400,
|
||||||
builder: (context, value, _) => LinearProgressIndicator(
|
),
|
||||||
value: value,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
minHeight: 8,
|
|
||||||
backgroundColor: Colors.grey.shade100,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
appTheme.blueA400,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
body: PageView(
|
||||||
body: PageView(
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
controller: pageController,
|
||||||
controller: pageController,
|
onPageChanged: (index) {
|
||||||
onPageChanged: (index) {
|
setState(() {
|
||||||
setState(() {
|
step = index + 1;
|
||||||
step = index + 1;
|
progressValue = step / totalSteps;
|
||||||
progressValue = step / totalSteps;
|
});
|
||||||
});
|
},
|
||||||
},
|
children: _pages(),
|
||||||
children: _pages(),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||||
|
|
||||||
|
/// Client-side validation for individual-person passport fields.
|
||||||
|
///
|
||||||
|
/// Messages match [mapConsultantValidationMessage] so 422 and local errors
|
||||||
|
/// look the same in the UI. Dates also reject non-calendar values and
|
||||||
|
/// dates after today.
|
||||||
|
abstract final class PassportDataValidator {
|
||||||
|
static const futureDateMessage = 'Дата не может быть в будущем';
|
||||||
|
|
||||||
|
static Map<String, String> validate(
|
||||||
|
ConsultantProfileModel consultant, {
|
||||||
|
DateTime? now,
|
||||||
|
}) {
|
||||||
|
final today = now ?? DateTime.now();
|
||||||
|
final errors = <String, String>{};
|
||||||
|
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'last_name',
|
||||||
|
value: consultant.lastName,
|
||||||
|
emptyMessage: 'Укажите фамилию',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'first_name',
|
||||||
|
value: consultant.firstName,
|
||||||
|
emptyMessage: 'Укажите имя',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'inn',
|
||||||
|
value: consultant.inn,
|
||||||
|
pattern: RegExp(r'^\d{12}$'),
|
||||||
|
emptyMessage: 'Укажите ИНН',
|
||||||
|
invalidMessage: 'Некорректный ИНН',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'snils',
|
||||||
|
value: consultant.snils,
|
||||||
|
pattern: RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$'),
|
||||||
|
emptyMessage: 'Укажите СНИЛС',
|
||||||
|
invalidMessage: 'Некорректный СНИЛС',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'passport_series',
|
||||||
|
value: consultant.passportSeries,
|
||||||
|
pattern: RegExp(r'^\d{4}$'),
|
||||||
|
emptyMessage: 'Укажите серию паспорта',
|
||||||
|
invalidMessage: 'Некорректная серия паспорта',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'passport_number',
|
||||||
|
value: consultant.passportNumber,
|
||||||
|
pattern: RegExp(r'^\d{6}$'),
|
||||||
|
emptyMessage: 'Укажите номер паспорта',
|
||||||
|
invalidMessage: 'Некорректный номер паспорта',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'passport_issuer',
|
||||||
|
value: consultant.passportIssuer,
|
||||||
|
emptyMessage: 'Укажите, кем выдан паспорт',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'passport_issuer_code',
|
||||||
|
value: consultant.passportIssuerCode,
|
||||||
|
pattern: RegExp(r'^\d{3}-\d{3}$'),
|
||||||
|
emptyMessage: 'Укажите код подразделения',
|
||||||
|
invalidMessage: 'Некорректный код подразделения',
|
||||||
|
);
|
||||||
|
_requireDate(
|
||||||
|
errors,
|
||||||
|
key: 'passport_issue_date',
|
||||||
|
value: consultant.passportIssueDate,
|
||||||
|
emptyMessage: 'Укажите дату выдачи паспорта',
|
||||||
|
invalidMessage: 'Некорректная дата выдачи',
|
||||||
|
today: today,
|
||||||
|
);
|
||||||
|
_requireDate(
|
||||||
|
errors,
|
||||||
|
key: 'birth_date',
|
||||||
|
value: consultant.birthDate,
|
||||||
|
emptyMessage: 'Укажите дату рождения',
|
||||||
|
invalidMessage: 'Некорректная дата рождения',
|
||||||
|
today: today,
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'birth_place',
|
||||||
|
value: consultant.birthPlace,
|
||||||
|
emptyMessage: 'Укажите место рождения',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'registration_address',
|
||||||
|
value: consultant.registrationAddress,
|
||||||
|
emptyMessage: 'Укажите адрес регистрации',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'residence_address',
|
||||||
|
value: consultant.residenceAddress,
|
||||||
|
emptyMessage: 'Укажите адрес проживания',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'citizenship',
|
||||||
|
value: consultant.citizenship,
|
||||||
|
pattern: RegExp(r'^[A-Za-z]{2}$'),
|
||||||
|
emptyMessage: 'Укажите гражданство',
|
||||||
|
invalidMessage: 'Некорректный код гражданства',
|
||||||
|
);
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _requireText(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required String emptyMessage,
|
||||||
|
}) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
errors[key] = emptyMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _requirePattern(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required RegExp pattern,
|
||||||
|
required String emptyMessage,
|
||||||
|
required String invalidMessage,
|
||||||
|
}) {
|
||||||
|
final raw = value?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
errors[key] = emptyMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pattern.hasMatch(raw)) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _requireDate(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required String emptyMessage,
|
||||||
|
required String invalidMessage,
|
||||||
|
required DateTime today,
|
||||||
|
}) {
|
||||||
|
final raw = value?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
errors[key] = emptyMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(raw);
|
||||||
|
if (match == null) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final year = int.parse(match.group(1)!);
|
||||||
|
final month = int.parse(match.group(2)!);
|
||||||
|
final day = int.parse(match.group(3)!);
|
||||||
|
final parsed = DateTime(year, month, day);
|
||||||
|
if (parsed.year != year || parsed.month != month || parsed.day != day) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final todayDate = DateTime(today.year, today.month, today.day);
|
||||||
|
if (parsed.isAfter(todayDate)) {
|
||||||
|
errors[key] = futureDateMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ class BlogPostMediaContent extends StatefulWidget {
|
|||||||
required this.onOpenGallery,
|
required this.onOpenGallery,
|
||||||
required this.onGalleryChanged,
|
required this.onGalleryChanged,
|
||||||
required this.videoUrlThumb,
|
required this.videoUrlThumb,
|
||||||
|
this.heroTag,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<FeedGalleryImage> galleryImages;
|
final List<FeedGalleryImage> galleryImages;
|
||||||
@@ -28,6 +29,9 @@ class BlogPostMediaContent extends StatefulWidget {
|
|||||||
final void Function(List<String> urls, int index) onOpenGallery;
|
final void Function(List<String> urls, int index) onOpenGallery;
|
||||||
final ValueChanged<int> onGalleryChanged;
|
final ValueChanged<int> onGalleryChanged;
|
||||||
|
|
||||||
|
/// Unique per post. Required when several cards with the same [imageUrl] share a route.
|
||||||
|
final Object? heroTag;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BlogPostMediaContent> createState() => _BlogPostMediaContentState();
|
State<BlogPostMediaContent> createState() => _BlogPostMediaContentState();
|
||||||
}
|
}
|
||||||
@@ -44,12 +48,14 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|||||||
bool _isCounterVisible = false;
|
bool _isCounterVisible = false;
|
||||||
Timer? _counterHideTimer;
|
Timer? _counterHideTimer;
|
||||||
|
|
||||||
bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
bool get _hasVideo =>
|
||||||
|
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
||||||
|
|
||||||
List<FeedGalleryImage> get _gallery {
|
List<FeedGalleryImage> get _gallery {
|
||||||
return [
|
return [
|
||||||
for (final image in widget.galleryImages)
|
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(
|
FeedGalleryImage(
|
||||||
url: url,
|
url: url,
|
||||||
orientation: image.orientation,
|
orientation: image.orientation,
|
||||||
@@ -71,7 +77,8 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == _gallery.length;
|
bool get _isCurrentSlideVideo =>
|
||||||
|
_hasVideo && widget.currentGalleryIndex == _gallery.length;
|
||||||
|
|
||||||
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
||||||
|
|
||||||
@@ -84,7 +91,9 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
double _heightForOrientation(String orientation, double width) {
|
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;
|
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
|
||||||
return (width / aspectRatio).clamp(180.0, maxHeight);
|
return (width / aspectRatio).clamp(180.0, maxHeight);
|
||||||
}
|
}
|
||||||
@@ -115,12 +124,15 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _handleCarouselScroll(ScrollNotification notification) {
|
bool _handleCarouselScroll(ScrollNotification notification) {
|
||||||
if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) {
|
if (notification is ScrollStartNotification ||
|
||||||
|
notification is ScrollUpdateNotification) {
|
||||||
_counterHideTimer?.cancel();
|
_counterHideTimer?.cancel();
|
||||||
if (!_isCounterVisible) {
|
if (!_isCounterVisible) {
|
||||||
setState(() => _isCounterVisible = true);
|
setState(() => _isCounterVisible = true);
|
||||||
}
|
}
|
||||||
} else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) {
|
} else if (notification is ScrollEndNotification &&
|
||||||
|
_isCounterVisible &&
|
||||||
|
_isCurrentSlideVideo) {
|
||||||
_scheduleCounterHideOnVideo();
|
_scheduleCounterHideOnVideo();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -140,17 +152,25 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _videoSlide() {
|
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(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
NotificationListener<ScrollNotification>(
|
NotificationListener<ScrollNotification>(
|
||||||
onNotification: _handleCarouselScroll,
|
onNotification: _handleCarouselScroll,
|
||||||
child: CarouselSlider.builder(
|
child: CarouselSlider.builder(
|
||||||
itemCount: itemCount,
|
itemCount: itemCount,
|
||||||
itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(),
|
itemBuilder: (context, itemIndex, _) =>
|
||||||
|
itemBuilder(context, itemIndex) ?? const SizedBox.shrink(),
|
||||||
options: CarouselOptions(
|
options: CarouselOptions(
|
||||||
viewportFraction: 1,
|
viewportFraction: 1,
|
||||||
height: height,
|
height: height,
|
||||||
@@ -167,7 +187,10 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
|||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: _shouldShowCounter ? 1 : 0,
|
opacity: _shouldShowCounter ? 1 : 0,
|
||||||
duration: const Duration(milliseconds: 150),
|
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<BlogPostMediaContent> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_hasVideo) {
|
if (_hasVideo) {
|
||||||
return SizedBox(
|
return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
|
||||||
height: mediaHeight,
|
|
||||||
width: width,
|
|
||||||
child: _videoSlide(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.imageUrl.isNotEmpty) {
|
if (widget.imageUrl.isNotEmpty) {
|
||||||
|
final image = CachedNetworkImage(
|
||||||
|
fit: BoxFit.fitWidth,
|
||||||
|
width: width,
|
||||||
|
imageUrl: widget.imageUrl,
|
||||||
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||||
|
);
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||||
child: Hero(
|
child: widget.heroTag == null
|
||||||
tag: widget.imageUrl,
|
? image
|
||||||
child: CachedNetworkImage(
|
: Hero(tag: widget.heroTag!, child: image),
|
||||||
fit: BoxFit.fitWidth,
|
|
||||||
width: width,
|
|
||||||
imageUrl: widget.imageUrl,
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,15 +258,27 @@ class _GalleryPhotoCounter extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
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(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'${currentIndex + 1} / $total',
|
'${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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -51,14 +51,18 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
|||||||
totalLikes = post.totalLikes;
|
totalLikes = post.totalLikes;
|
||||||
return;
|
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;
|
isLiked = post.wasLikedByMe;
|
||||||
totalLikes = post.totalLikes;
|
totalLikes = post.totalLikes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openGallery(List<String> imageUrls, int index) {
|
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
context.pushTransparentRoute(
|
||||||
|
rootNavigator: true,
|
||||||
|
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleLike() async {
|
Future<void> _toggleLike() async {
|
||||||
@@ -82,7 +86,8 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
|||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
onTap: () =>
|
||||||
|
GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||||
child: Material(
|
child: Material(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -96,10 +101,19 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
|||||||
BlogPostMediaContent(
|
BlogPostMediaContent(
|
||||||
galleryImages: post.images,
|
galleryImages: post.images,
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||||
|
? embedUrl
|
||||||
|
: null,
|
||||||
videoUrlThumb: post.videoUrlThumb,
|
videoUrlThumb: post.videoUrlThumb,
|
||||||
currentGalleryIndex: currentGalleryIndex,
|
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) {
|
onGalleryChanged: (index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
currentGalleryIndex = index;
|
currentGalleryIndex = index;
|
||||||
@@ -107,7 +121,12 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -129,11 +148,19 @@ class _BlogPostHeader extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug),
|
onTap: () => GoRouter.of(
|
||||||
|
context,
|
||||||
|
).pushNamed('profileScreen', extra: post.author.authorSlug),
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 20,
|
radius: 20,
|
||||||
backgroundColor: Colors.black12,
|
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'),
|
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -145,17 +172,27 @@ class _BlogPostHeader extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
post.author.authorName,
|
post.author.authorName,
|
||||||
overflow: TextOverflow.ellipsis,
|
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),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
'${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(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: SubscribeButtonWidget(slug: post.author.authorSlug),
|
child: SubscribeButtonWidget(slug: post.author.authorSlug),
|
||||||
@@ -190,33 +227,51 @@ class _BlogPostTextContent extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
post.title,
|
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.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)
|
if (post.description.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: MarkdownHtmlWidget(markdownText: _truncateDescription(post.description)),
|
child: MarkdownHtmlWidget(
|
||||||
|
markdownText: _truncateDescription(post.description),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (post.description.length > 200)
|
if (post.description.length > 200)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Text(
|
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 {
|
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 FeedBlogPostModel post;
|
||||||
final bool isLiked;
|
final bool isLiked;
|
||||||
@@ -230,7 +285,9 @@ class _BlogPostActionsRow extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
FeedIconButton(
|
FeedIconButton(
|
||||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
icon: isLiked
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
text: totalLikes.toString(),
|
text: totalLikes.toString(),
|
||||||
iconColor: 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,
|
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||||
@@ -239,19 +296,34 @@ class _BlogPostActionsRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
FeedIconButton(
|
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(),
|
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),
|
const SizedBox(width: 8),
|
||||||
FeedIconButton(
|
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: () {
|
onTap: () {
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
SharePlus.instance.share(
|
SharePlus.instance.share(
|
||||||
ShareParams(
|
ShareParams(
|
||||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
|
uri: Uri.parse(
|
||||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
'${sl<AppConfig>().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),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
post.totalViews.toString(),
|
post.totalViews.toString(),
|
||||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
final ConsultantRepository consultantRepository;
|
final ConsultantRepository consultantRepository;
|
||||||
String _currentPeriod = '1';
|
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());
|
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
|
||||||
|
|
||||||
Future<void> getProfileBySlug(String slug) async {
|
Future<void> getProfileBySlug(String slug) async {
|
||||||
@@ -49,6 +52,7 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> getMyProfile({String period = '1'}) async {
|
Future<void> getMyProfile({String period = '1'}) async {
|
||||||
|
myProfileCubit = this;
|
||||||
emit(const ProfileState.loading());
|
emit(const ProfileState.loading());
|
||||||
_currentPeriod = period;
|
_currentPeriod = period;
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +78,22 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reloads only the blog posts list without resetting the whole profile screen.
|
||||||
|
Future<void> 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) {
|
void updateFeedBlogPostList(FeedBlogPostModel feedBlogPostModel, bool wasLikedByMe) {
|
||||||
List<FeedBlogPostModel> feedBlogPostList = [...(state as ProfileStateLoaded).blogPosts];
|
List<FeedBlogPostModel> feedBlogPostList = [...(state as ProfileStateLoaded).blogPosts];
|
||||||
final int index = feedBlogPostList.indexOf(feedBlogPostModel);
|
final int index = feedBlogPostList.indexOf(feedBlogPostModel);
|
||||||
|
|||||||
@@ -436,8 +436,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
GoRouter.of(context).pushNamed('blogPostCreateScreen');
|
await GoRouter.of(context).pushNamed('blogPostCreateScreen');
|
||||||
|
if (mounted) {
|
||||||
|
await profileCubit.reloadBlogPosts();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ class ProfileBlogPostWidget extends StatefulWidget {
|
|||||||
final int index;
|
final int index;
|
||||||
final ProfileCubit profileCubit;
|
final ProfileCubit profileCubit;
|
||||||
|
|
||||||
const ProfileBlogPostWidget({super.key, required this.index, required this.profileCubit});
|
const ProfileBlogPostWidget({
|
||||||
|
super.key,
|
||||||
|
required this.index,
|
||||||
|
required this.profileCubit,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProfileBlogPostWidget> createState() => _ProfileBlogPostWidgetState();
|
State<ProfileBlogPostWidget> createState() => _ProfileBlogPostWidgetState();
|
||||||
@@ -35,8 +39,11 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
late int totalLikes;
|
late int totalLikes;
|
||||||
int currentGalleryIndex = 0;
|
int currentGalleryIndex = 0;
|
||||||
|
|
||||||
void _openGallery(List<String> imageUrls, int index) {
|
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
context.pushTransparentRoute(
|
||||||
|
rootNavigator: true,
|
||||||
|
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -56,9 +63,13 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
onTap: () =>
|
||||||
|
GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white),
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -74,9 +85,12 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
foregroundImage: NetworkImage(
|
foregroundImage: NetworkImage(
|
||||||
post.author.authorProfileIcon.contains('https')
|
post.author.authorProfileIcon.contains('https')
|
||||||
? post.author.authorProfileIcon
|
? post.author.authorProfileIcon
|
||||||
: sl<AppConfig>().baseUrl + post.author.authorProfileIcon,
|
: sl<AppConfig>().baseUrl +
|
||||||
|
post.author.authorProfileIcon,
|
||||||
|
),
|
||||||
|
backgroundImage: const AssetImage(
|
||||||
|
'assets/images/no_avatar.png',
|
||||||
),
|
),
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Column(
|
Column(
|
||||||
@@ -84,12 +98,19 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
post.author.authorName,
|
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),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
'${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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -101,6 +122,7 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
id: post.id,
|
id: post.id,
|
||||||
feedBlogPostModel: post,
|
feedBlogPostModel: post,
|
||||||
userSlug: post.author.authorSlug,
|
userSlug: post.author.authorSlug,
|
||||||
|
onPostsChanged: widget.profileCubit.reloadBlogPosts,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -111,12 +133,17 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
post.title,
|
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.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)
|
if (post.description.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
@@ -131,17 +158,34 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Text(
|
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(
|
BlogPostMediaContent(
|
||||||
galleryImages: post.images,
|
galleryImages: post.images,
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||||
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
? embedUrl
|
||||||
|
: null,
|
||||||
|
videoUrlThumb: post.videoUrlThumb != null
|
||||||
|
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
|
||||||
|
: null,
|
||||||
currentGalleryIndex: currentGalleryIndex,
|
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) {
|
onGalleryChanged: (index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
currentGalleryIndex = index;
|
currentGalleryIndex = index;
|
||||||
@@ -154,18 +198,35 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
FeedIconButton(
|
FeedIconButton(
|
||||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
icon: isLiked
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
text: totalLikes.toString(),
|
text: totalLikes.toString(),
|
||||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
iconColor: isLiked
|
||||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
? Colors.red.withValues(alpha: 0.8)
|
||||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
: null,
|
||||||
|
backgroundColor: isLiked
|
||||||
|
? Colors.red.withValues(alpha: 0.1)
|
||||||
|
: null,
|
||||||
|
textColor: isLiked
|
||||||
|
? Colors.red.withValues(alpha: 0.8)
|
||||||
|
: null,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
setState(() {
|
setState(() {
|
||||||
isLiked = !isLiked;
|
isLiked = !isLiked;
|
||||||
isLiked ? totalLikes++ : totalLikes--;
|
isLiked ? totalLikes++ : totalLikes--;
|
||||||
});
|
});
|
||||||
isLiked ? await context.read<FeedCubit>().likePost(post.id) : await context.read<FeedCubit>().unlikedPost(post.id);
|
isLiked
|
||||||
widget.profileCubit.updateFeedBlogPostList(post, isLiked ? false : true);
|
? await context.read<FeedCubit>().likePost(
|
||||||
|
post.id,
|
||||||
|
)
|
||||||
|
: await context.read<FeedCubit>().unlikedPost(
|
||||||
|
post.id,
|
||||||
|
);
|
||||||
|
widget.profileCubit.updateFeedBlogPostList(
|
||||||
|
post,
|
||||||
|
isLiked ? false : true,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -174,10 +235,15 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
'assets/icons/comment.svg',
|
'assets/icons/comment.svg',
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
colorFilter: ColorFilter.mode(
|
||||||
|
appTheme.gray600,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
text: post.totalComments.toString(),
|
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),
|
const SizedBox(width: 8),
|
||||||
FeedIconButton(
|
FeedIconButton(
|
||||||
@@ -185,24 +251,38 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
|||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
'assets/icons/share.svg',
|
'assets/icons/share.svg',
|
||||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
colorFilter: ColorFilter.mode(
|
||||||
|
appTheme.gray600,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
SharePlus.instance.share(
|
SharePlus.instance.share(
|
||||||
ShareParams(
|
ShareParams(
|
||||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
|
uri: Uri.parse(
|
||||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
'${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}',
|
||||||
|
),
|
||||||
|
sharePositionOrigin:
|
||||||
|
box!.localToGlobal(Offset.zero) & box.size,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
Icon(
|
||||||
|
Icons.visibility_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
post.totalViews.toString(),
|
post.totalViews.toString(),
|
||||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: eatvio_app
|
name: eatvio_app
|
||||||
description: A new Flutter project.
|
description: A new Flutter project.
|
||||||
version: 1.3.36+125
|
version: 1.3.39+128
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -21,10 +21,18 @@ void main() {
|
|||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
blogPostRepository = FixtureBlogPostRepository();
|
blogPostRepository = FixtureBlogPostRepository();
|
||||||
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
blogPostCreateCubit = BlogPostCreateCubit(
|
||||||
fullCreateFixture = FixtureReader.readAsMap('blog_post_create/create_request_full.json');
|
blogPostRepository: blogPostRepository,
|
||||||
updateFixture = FixtureReader.readAsMap('blog_post_create/update_request.json');
|
);
|
||||||
deleteFixture = FixtureReader.readAsMap('blog_post_create/delete_request.json');
|
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(() {
|
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
|
// Arrange
|
||||||
Widget formWidget = Column(
|
Widget formWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -69,7 +79,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
videoUrl: fullCreateFixture['video_url'] as String?,
|
videoUrl: fullCreateFixture['video_url'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Опубликовать'),
|
child: const Text('Опубликовать'),
|
||||||
@@ -81,9 +90,18 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String);
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String);
|
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.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
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
|
// Arrange
|
||||||
final expectedPayload = Map<String, dynamic>.from(fullCreateFixture);
|
final expectedPayload = Map<String, dynamic>.from(fullCreateFixture);
|
||||||
expectedPayload['video_url'] = null;
|
expectedPayload['video_url'] = null;
|
||||||
@@ -129,7 +149,11 @@ void main() {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
Icon(
|
||||||
|
Icons.add_photo_alternate,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text('Добавить изображение'),
|
Text('Добавить изображение'),
|
||||||
],
|
],
|
||||||
@@ -145,7 +169,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
image: null, // В реальном тесте здесь был бы XFile
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Опубликовать'),
|
child: const Text('Опубликовать'),
|
||||||
@@ -157,15 +180,24 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String);
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String);
|
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.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(blogPostRepository.createPayloads.single.toJson(), expectedPayload);
|
expect(
|
||||||
|
blogPostRepository.createPayloads.single.toJson(),
|
||||||
|
expectedPayload,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle blog post update', (WidgetTester tester) async {
|
testWidgets('should handle blog post update', (WidgetTester tester) async {
|
||||||
@@ -175,7 +207,12 @@ void main() {
|
|||||||
state: 'published',
|
state: 'published',
|
||||||
image: 'original_image.jpg',
|
image: 'original_image.jpg',
|
||||||
description: 'Original Description',
|
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',
|
videoUrl: 'https://youtube.com/watch?v=original',
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
@@ -216,8 +253,9 @@ void main() {
|
|||||||
title: updateFixture['title'] as String?,
|
title: updateFixture['title'] as String?,
|
||||||
description: updateFixture['description_md'] as String?,
|
description: updateFixture['description_md'] as String?,
|
||||||
videoUrl: updateFixture['video_url'] as String?,
|
videoUrl: updateFixture['video_url'] as String?,
|
||||||
image: null,
|
existingMediaIds: List<int>.from(
|
||||||
oldImageUrl: updateFixture['old_image_url'] as String?,
|
updateFixture['existing_media_ids'] as List,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Обновить'),
|
child: const Text('Обновить'),
|
||||||
@@ -229,9 +267,18 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), updateFixture['title'] as String);
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), updateFixture['description_md'] as String);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), updateFixture['video_url'] as String);
|
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.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
@@ -241,7 +288,9 @@ void main() {
|
|||||||
expect(blogPostRepository.updatePayloads.single.toJson(), updateFixture);
|
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
|
// Arrange
|
||||||
Widget deleteWidget = ElevatedButton(
|
Widget deleteWidget = ElevatedButton(
|
||||||
key: const Key('deleteBlogPostButton'),
|
key: const Key('deleteBlogPostButton'),
|
||||||
@@ -262,18 +311,21 @@ void main() {
|
|||||||
expect(blogPostRepository.deleteCalls.single, deleteFixture['id']);
|
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
|
// Arrange
|
||||||
blogPostRepository.createDelay = const Duration(milliseconds: 50);
|
blogPostRepository.createDelay = const Duration(milliseconds: 50);
|
||||||
|
|
||||||
Widget loadingWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget loadingWidget =
|
||||||
builder: (context, state) {
|
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateLoading) {
|
builder: (context, state) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
if (state.isLoading) {
|
||||||
}
|
return const Center(child: CircularProgressIndicator());
|
||||||
return const Text('Not Loading');
|
}
|
||||||
},
|
return const Text('Not Loading');
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(loadingWidget));
|
await tester.pumpWidget(createTestWidget(loadingWidget));
|
||||||
@@ -284,7 +336,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(); // Need to pump twice to get the loading state
|
await tester.pump(); // Need to pump twice to get the loading state
|
||||||
@@ -297,18 +348,21 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
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
|
// Arrange
|
||||||
blogPostRepository.createError = Exception('Network error');
|
blogPostRepository.createError = Exception('Network error');
|
||||||
|
|
||||||
Widget errorWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget errorWidget =
|
||||||
builder: (context, state) {
|
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateError) {
|
builder: (context, state) {
|
||||||
return Center(child: Text('Error: ${state.errorMessage}'));
|
if (state.errorMessage != null) {
|
||||||
}
|
return Center(child: Text('Error: ${state.errorMessage}'));
|
||||||
return const Text('No Error');
|
}
|
||||||
},
|
return const Text('No Error');
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(errorWidget));
|
await tester.pumpWidget(createTestWidget(errorWidget));
|
||||||
@@ -319,7 +373,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(); // Need to pump twice to get the error state
|
await tester.pump(); // Need to pump twice to get the error state
|
||||||
@@ -328,16 +381,21 @@ void main() {
|
|||||||
expect(find.textContaining('Error:'), findsOneWidget);
|
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
|
// Arrange
|
||||||
Widget successWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget successWidget =
|
||||||
builder: (context, state) {
|
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
builder: (context, state) {
|
||||||
return const Center(child: Text('Blog post created successfully!'));
|
if (state.isSuccess) {
|
||||||
}
|
return const Center(
|
||||||
return const Text('Not Loaded');
|
child: Text('Blog post created successfully!'),
|
||||||
},
|
);
|
||||||
);
|
}
|
||||||
|
return const Text('Not Loaded');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(successWidget));
|
await tester.pumpWidget(createTestWidget(successWidget));
|
||||||
@@ -348,7 +406,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(); // Need to pump twice to get the success state
|
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);
|
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
|
// Arrange
|
||||||
Widget validationWidget = Column(
|
Widget validationWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок не может быть пустым'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
errorText: 'Заголовок не может быть пустым',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание не может быть пустым'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Описание',
|
||||||
|
errorText: 'Описание не может быть пустым',
|
||||||
|
),
|
||||||
maxLines: 6,
|
maxLines: 6,
|
||||||
minLines: 4,
|
minLines: 4,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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);
|
expect(find.text('Описание не может быть пустым'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle image picker interaction', (WidgetTester tester) async {
|
testWidgets('should handle image picker interaction', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
bool imagePickerCalled = false;
|
bool imagePickerCalled = false;
|
||||||
Widget imagePickerWidget = GestureDetector(
|
Widget imagePickerWidget = GestureDetector(
|
||||||
@@ -425,16 +496,25 @@ void main() {
|
|||||||
expect(imagePickerCalled, isTrue);
|
expect(imagePickerCalled, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle video URL validation', (WidgetTester tester) async {
|
testWidgets('should handle video URL validation', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget videoValidationWidget = Column(
|
Widget videoValidationWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Ссылка на видео', errorText: 'Введите корректную ссылку на видео'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Ссылка на видео',
|
||||||
|
errorText: 'Введите корректную ссылку на видео',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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);
|
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
|
// Arrange
|
||||||
Widget emptyFormWidget = Column(
|
Widget emptyFormWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -465,7 +547,11 @@ void main() {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await blogPostCreateCubit.createBlogPost(title: '', videoUrl: '', description: '', image: null);
|
await blogPostCreateCubit.createBlogPost(
|
||||||
|
title: '',
|
||||||
|
videoUrl: '',
|
||||||
|
description: '',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Опубликовать'),
|
child: const Text('Опубликовать'),
|
||||||
),
|
),
|
||||||
@@ -479,14 +565,14 @@ void main() {
|
|||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert — invalid empty payload must not reach the repository
|
||||||
expect(
|
expect(blogPostRepository.createPayloads, isEmpty);
|
||||||
blogPostRepository.createPayloads.single.toJson(),
|
expect(blogPostCreateCubit.state.hasValidationErrors, isTrue);
|
||||||
{'title': '', 'description_md': '', 'video_url': '', 'image_name': null},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
// Arrange
|
||||||
const longContent =
|
const longContent =
|
||||||
'Это очень длинное описание блог поста, которое содержит много текста и должно быть правильно обработано системой. '
|
'Это очень длинное описание блог поста, которое содержит много текста и должно быть правильно обработано системой. '
|
||||||
@@ -510,7 +596,11 @@ void main() {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
onPressed: () async {
|
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('Опубликовать'),
|
child: const Text('Опубликовать'),
|
||||||
),
|
),
|
||||||
@@ -521,23 +611,26 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget(longContentWidget));
|
await tester.pumpWidget(createTestWidget(longContentWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'Test Title');
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), longContent);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
|
'Test Title',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
longContent,
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(
|
expect(blogPostRepository.createPayloads.single.toJson(), {
|
||||||
blogPostRepository.createPayloads.single.toJson(),
|
'title': 'Test Title',
|
||||||
{
|
'description_md': longContent,
|
||||||
'title': 'Test Title',
|
'video_url': null,
|
||||||
'description_md': longContent,
|
'image_name': null,
|
||||||
'video_url': null,
|
});
|
||||||
'image_name': null,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ void main() {
|
|||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
blogPostRepository = FixtureBlogPostRepository();
|
blogPostRepository = FixtureBlogPostRepository();
|
||||||
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
blogPostCreateCubit = BlogPostCreateCubit(
|
||||||
|
blogPostRepository: blogPostRepository,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
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
|
// Arrange & Act
|
||||||
final cubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
final cubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
||||||
|
|
||||||
@@ -41,13 +45,18 @@ void main() {
|
|||||||
cubit.close();
|
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
|
// Arrange
|
||||||
Widget formWidget = Column(
|
Widget formWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
hintText: 'Введите заголовок поста',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
@@ -62,7 +71,11 @@ void main() {
|
|||||||
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget);
|
expect(
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget);
|
findsOneWidget,
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), 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
|
// Arrange
|
||||||
Widget formWidget = TextFormField(
|
Widget formWidget = TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
hintText: 'Введите заголовок поста',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
@@ -90,36 +120,47 @@ void main() {
|
|||||||
const testTitle = 'Тестовый заголовок поста';
|
const testTitle = 'Тестовый заголовок поста';
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), testTitle);
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
|
testTitle,
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.text(testTitle), findsOneWidget);
|
expect(find.text(testTitle), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should update description when user types in description field', (WidgetTester tester) async {
|
testWidgets(
|
||||||
// Arrange
|
'should update description when user types in description field',
|
||||||
Widget formWidget = TextFormField(
|
(WidgetTester tester) async {
|
||||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
// Arrange
|
||||||
decoration: const InputDecoration(labelText: 'Описание'),
|
Widget formWidget = TextFormField(
|
||||||
maxLines: 6,
|
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||||
minLines: 4,
|
decoration: const InputDecoration(labelText: 'Описание'),
|
||||||
);
|
maxLines: 6,
|
||||||
|
minLines: 4,
|
||||||
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
const testDescription = 'Это тестовое описание блог поста';
|
const testDescription = 'Это тестовое описание блог поста';
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), testDescription);
|
await tester.enterText(
|
||||||
await tester.pump();
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
testDescription,
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.text(testDescription), findsOneWidget);
|
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
|
// Arrange
|
||||||
Widget formWidget = TextFormField(
|
Widget formWidget = TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||||
@@ -132,16 +173,27 @@ void main() {
|
|||||||
const testVideoUrl = 'https://youtube.com/watch?v=example';
|
const testVideoUrl = 'https://youtube.com/watch?v=example';
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), testVideoUrl);
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
testVideoUrl,
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.text(testVideoUrl), findsOneWidget);
|
expect(find.text(testVideoUrl), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display form labels correctly', (WidgetTester tester) async {
|
testWidgets('should display form labels correctly', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget formWidget = const Column(children: [Text('Заголовок'), Text('Описание'), Text('Ссылка на видео')]);
|
Widget formWidget = const Column(
|
||||||
|
children: [
|
||||||
|
Text('Заголовок'),
|
||||||
|
Text('Описание'),
|
||||||
|
Text('Ссылка на видео'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
@@ -153,11 +205,16 @@ void main() {
|
|||||||
expect(find.text('Ссылка на видео'), findsOneWidget);
|
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
|
// Arrange
|
||||||
Widget formWidget = TextFormField(
|
Widget formWidget = TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
hintText: 'Введите заголовок поста',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -170,14 +227,21 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('should display create button', (WidgetTester tester) async {
|
testWidgets('should display create button', (WidgetTester tester) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget formWidget = ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать'));
|
Widget formWidget = ElevatedButton(
|
||||||
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
|
onPressed: () {},
|
||||||
|
child: const Text('Опубликовать'),
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget);
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
expect(find.text('Опубликовать'), findsOneWidget);
|
expect(find.text('Опубликовать'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,19 +281,26 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('should display error message', (WidgetTester tester) async {
|
testWidgets('should display error message', (WidgetTester tester) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget errorWidget = const Center(child: Text('Произошла ошибка при создании блог поста'));
|
Widget errorWidget = const Center(
|
||||||
|
child: Text('Произошла ошибка при создании блог поста'),
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(errorWidget));
|
await tester.pumpWidget(createTestWidget(errorWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.text('Произошла ошибка при создании блог поста'), findsOneWidget);
|
expect(
|
||||||
|
find.text('Произошла ошибка при создании блог поста'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display success message', (WidgetTester tester) async {
|
testWidgets('should display success message', (WidgetTester tester) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget successWidget = const Center(child: Text('Блог пост успешно создан!'));
|
Widget successWidget = const Center(
|
||||||
|
child: Text('Блог пост успешно создан!'),
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(successWidget));
|
await tester.pumpWidget(createTestWidget(successWidget));
|
||||||
@@ -245,12 +316,18 @@ void main() {
|
|||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок обязателен'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
errorText: 'Заголовок обязателен',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание обязательно'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Описание',
|
||||||
|
errorText: 'Описание обязательно',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -264,13 +341,18 @@ void main() {
|
|||||||
expect(find.text('Описание обязательно'), findsOneWidget);
|
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
|
// Arrange
|
||||||
Widget completeForm = Column(
|
Widget completeForm = Column(
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
hintText: 'Введите заголовок поста',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
@@ -285,7 +367,11 @@ void main() {
|
|||||||
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget);
|
expect(
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget);
|
findsOneWidget,
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), 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
|
// Arrange
|
||||||
Widget imageWidget = GestureDetector(
|
Widget imageWidget = GestureDetector(
|
||||||
key: AppKeys.imageWidget,
|
key: AppKeys.imageWidget,
|
||||||
@@ -373,68 +473,101 @@ void main() {
|
|||||||
expect(imageUploadTapped, isTrue);
|
expect(imageUploadTapped, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display blog post creation form with image and video options', (WidgetTester tester) async {
|
testWidgets(
|
||||||
// Arrange
|
'should display blog post creation form with image and video options',
|
||||||
Widget completeForm = Column(
|
(WidgetTester tester) async {
|
||||||
children: [
|
// Arrange
|
||||||
const Text('Заголовок'),
|
Widget completeForm = Column(
|
||||||
const SizedBox(height: 8),
|
children: [
|
||||||
TextFormField(
|
const Text('Заголовок'),
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
const SizedBox(height: 8),
|
||||||
decoration: const InputDecoration(hintText: 'Введите заголовок поста'),
|
TextFormField(
|
||||||
),
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
const SizedBox(height: 16),
|
decoration: const InputDecoration(
|
||||||
const Text('Описание'),
|
hintText: 'Введите заголовок поста',
|
||||||
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(
|
const SizedBox(height: 16),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
const Text('Описание'),
|
||||||
children: [
|
const SizedBox(height: 8),
|
||||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
TextFormField(
|
||||||
SizedBox(height: 8),
|
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||||
Text('Добавить изображение'),
|
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 SizedBox(height: 16),
|
const Text('Ссылка на видео'),
|
||||||
const Text('Ссылка на видео'),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 8),
|
TextFormField(
|
||||||
TextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration()),
|
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||||
const SizedBox(height: 32),
|
decoration: const InputDecoration(),
|
||||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
),
|
||||||
],
|
const SizedBox(height: 32),
|
||||||
);
|
ElevatedButton(
|
||||||
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
|
onPressed: () {},
|
||||||
|
child: const Text('Опубликовать'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(completeForm));
|
await tester.pumpWidget(createTestWidget(completeForm));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.text('Заголовок'), findsOneWidget);
|
expect(find.text('Заголовок'), findsOneWidget);
|
||||||
expect(find.text('Описание'), findsOneWidget);
|
expect(find.text('Описание'), findsOneWidget);
|
||||||
expect(find.text('Изображение поста'), findsOneWidget);
|
expect(find.text('Изображение поста'), findsOneWidget);
|
||||||
expect(find.text('Ссылка на видео'), findsOneWidget);
|
expect(find.text('Ссылка на видео'), findsOneWidget);
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget);
|
expect(
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
expect(find.byKey(AppKeys.imageWidget), findsOneWidget);
|
findsOneWidget,
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget);
|
);
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_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/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/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:flutter_test/flutter_test.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
@@ -14,6 +16,8 @@ void main() {
|
|||||||
setUp(() {
|
setUp(() {
|
||||||
mockRepository = MockBlogPostRepository();
|
mockRepository = MockBlogPostRepository();
|
||||||
cubit = BlogPostCreateCubit(blogPostRepository: mockRepository);
|
cubit = BlogPostCreateCubit(blogPostRepository: mockRepository);
|
||||||
|
registerFallbackValue(<XFile>[]);
|
||||||
|
registerFallbackValue(<int>[]);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
@@ -21,63 +25,246 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('начальное состояние - BlogPostCreateState', () {
|
test('начальное состояние - BlogPostCreateState', () {
|
||||||
expect(cubit.state, isA<BlogPostCreateState>());
|
expect(cubit.state, const BlogPostCreateState());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createBlogPost - успешное создание', () async {
|
test('validateForm - пустая форма заполняет formError', () {
|
||||||
when(() => mockRepository.createBlogPost(any(), any(), any(), any()))
|
final isValid = cubit.validateForm(
|
||||||
.thenAnswer((_) async => {});
|
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(result, true);
|
||||||
expect(cubit.state, isA<BlogPostCreateStateLoaded>());
|
expect(cubit.state.isSuccess, true);
|
||||||
verify(() => mockRepository.createBlogPost(null, null, null, null)).called(1);
|
verify(
|
||||||
|
() => mockRepository.createBlogPost(
|
||||||
|
title: 'Test Title',
|
||||||
|
videoUrl: null,
|
||||||
|
description: 'Test Description',
|
||||||
|
images: const [],
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createBlogPost - обработка ошибки', () async {
|
test('createBlogPost - обработка ошибки', () async {
|
||||||
when(() => mockRepository.createBlogPost(any(), any(), any(), any()))
|
when(
|
||||||
.thenThrow(Exception('Error'));
|
() => 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(result, false);
|
||||||
expect(cubit.state, isA<BlogPostCreateStateError>());
|
expect(cubit.state.errorMessage, contains('Error'));
|
||||||
final errorState = cubit.state as BlogPostCreateStateError;
|
expect(cubit.state.isSuccess, false);
|
||||||
expect(errorState.errorMessage, contains('Error'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
test('updateBlogPost - успешное обновление', () async {
|
||||||
const id = 1;
|
const id = 1;
|
||||||
const title = 'Updated Title';
|
const title = 'Updated Title';
|
||||||
|
|
||||||
when(() => mockRepository.updateBlogPost(any(), any(), any(), any(), any(), any()))
|
when(
|
||||||
.thenAnswer((_) async => {});
|
() => 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(result, true);
|
||||||
expect(cubit.state, isA<BlogPostCreateStateLoaded>());
|
expect(cubit.state.isSuccess, true);
|
||||||
verify(() => mockRepository.updateBlogPost(id, title, null, null, null, null)).called(1);
|
verify(
|
||||||
|
() => mockRepository.updateBlogPost(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
videoUrl: '',
|
||||||
|
description: '',
|
||||||
|
existingMediaIds: [2, 1],
|
||||||
|
newImages: const [],
|
||||||
|
mediaIdsToDelete: [9],
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateBlogPost - обработка ошибки', () async {
|
test('updateBlogPost - обработка ошибки', () async {
|
||||||
const id = 1;
|
const id = 1;
|
||||||
|
|
||||||
when(() => mockRepository.updateBlogPost(any(), any(), any(), any(), any(), any()))
|
when(
|
||||||
.thenThrow(Exception('Error'));
|
() => 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(result, false);
|
||||||
expect(cubit.state, isA<BlogPostCreateStateError>());
|
expect(cubit.state.errorMessage, contains('Error'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleteBlogPost - успешное удаление', () async {
|
test('deleteBlogPost - успешное удаление', () async {
|
||||||
const id = 1;
|
const id = 1;
|
||||||
|
|
||||||
when(() => mockRepository.deleteBlogPost(id))
|
when(() => mockRepository.deleteBlogPost(id)).thenAnswer((_) async => {});
|
||||||
.thenAnswer((_) async => {});
|
|
||||||
|
|
||||||
final result = await cubit.deleteBlogPost(id);
|
final result = await cubit.deleteBlogPost(id);
|
||||||
|
|
||||||
@@ -88,13 +275,27 @@ void main() {
|
|||||||
test('deleteBlogPost - обработка ошибки', () async {
|
test('deleteBlogPost - обработка ошибки', () async {
|
||||||
const id = 1;
|
const id = 1;
|
||||||
|
|
||||||
when(() => mockRepository.deleteBlogPost(id))
|
when(
|
||||||
.thenThrow(Exception('Error'));
|
() => mockRepository.deleteBlogPost(id),
|
||||||
|
).thenThrow(Exception('Error'));
|
||||||
|
|
||||||
final result = await cubit.deleteBlogPost(id);
|
final result = await cubit.deleteBlogPost(id);
|
||||||
|
|
||||||
expect(result, false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,16 @@ void main() {
|
|||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
blogPostRepository = FixtureBlogPostRepository();
|
blogPostRepository = FixtureBlogPostRepository();
|
||||||
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
blogPostCreateCubit = BlogPostCreateCubit(
|
||||||
|
blogPostRepository: blogPostRepository,
|
||||||
|
);
|
||||||
feedProbe = FeedReloadProbe();
|
feedProbe = FeedReloadProbe();
|
||||||
fullCreateFixture = FixtureReader.readAsMap('blog_post_create/create_request_full.json');
|
fullCreateFixture = FixtureReader.readAsMap(
|
||||||
updateFixture = FixtureReader.readAsMap('blog_post_create/update_request.json');
|
'blog_post_create/create_request_full.json',
|
||||||
|
);
|
||||||
|
updateFixture = FixtureReader.readAsMap(
|
||||||
|
'blog_post_create/update_request.json',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
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
|
// Arrange
|
||||||
Widget fullFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget
|
||||||
|
fullFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
if (state.isSuccess) {
|
||||||
feedProbe.loadFeedBlogPostList();
|
feedProbe.loadFeedBlogPostList();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -82,7 +91,11 @@ void main() {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
Icon(
|
||||||
|
Icons.add_photo_alternate,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text('Добавить изображение'),
|
Text('Добавить изображение'),
|
||||||
],
|
],
|
||||||
@@ -98,7 +111,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: fullCreateFixture['video_url'] as String?,
|
videoUrl: fullCreateFixture['video_url'] as String?,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Опубликовать'),
|
child: const Text('Опубликовать'),
|
||||||
@@ -111,27 +123,46 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget(fullFlowWidget));
|
await tester.pumpWidget(createTestWidget(fullFlowWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String);
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String);
|
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.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(blogPostRepository.createPayloads.single.toJson(), fullCreateFixture);
|
expect(
|
||||||
|
blogPostRepository.createPayloads.single.toJson(),
|
||||||
|
fullCreateFixture,
|
||||||
|
);
|
||||||
expect(feedProbe.loadRequests, 1);
|
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
|
// Arrange
|
||||||
final existingBlogPost = FeedBlogPostModel(
|
final existingBlogPost = FeedBlogPostModel(
|
||||||
id: 1,
|
id: 1,
|
||||||
state: 'published',
|
state: 'published',
|
||||||
image: 'original_image.jpg',
|
image: 'original_image.jpg',
|
||||||
description: 'Original Description',
|
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',
|
videoUrl: 'https://youtube.com/watch?v=original',
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
@@ -142,9 +173,10 @@ void main() {
|
|||||||
wasLikedByMe: false,
|
wasLikedByMe: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget updateFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget
|
||||||
|
updateFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
if (state.isSuccess) {
|
||||||
feedProbe.loadFeedBlogPostList();
|
feedProbe.loadFeedBlogPostList();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -161,13 +193,17 @@ void main() {
|
|||||||
decoration: const InputDecoration(labelText: 'Описание'),
|
decoration: const InputDecoration(labelText: 'Описание'),
|
||||||
maxLines: 6,
|
maxLines: 6,
|
||||||
minLines: 4,
|
minLines: 4,
|
||||||
controller: TextEditingController(text: existingBlogPost.description),
|
controller: TextEditingController(
|
||||||
|
text: existingBlogPost.description,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
||||||
controller: TextEditingController(text: existingBlogPost.videoUrl),
|
controller: TextEditingController(
|
||||||
|
text: existingBlogPost.videoUrl,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
@@ -178,8 +214,9 @@ void main() {
|
|||||||
title: updateFixture['title'] as String?,
|
title: updateFixture['title'] as String?,
|
||||||
videoUrl: updateFixture['video_url'] as String?,
|
videoUrl: updateFixture['video_url'] as String?,
|
||||||
description: updateFixture['description_md'] as String?,
|
description: updateFixture['description_md'] as String?,
|
||||||
image: null,
|
existingMediaIds: List<int>.from(
|
||||||
oldImageUrl: updateFixture['old_image_url'] as String?,
|
updateFixture['existing_media_ids'] as List,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Обновить'),
|
child: const Text('Обновить'),
|
||||||
@@ -192,9 +229,18 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget(updateFlowWidget));
|
await tester.pumpWidget(createTestWidget(updateFlowWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), updateFixture['title'] as String);
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), updateFixture['description_md'] as String);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), updateFixture['video_url'] as String);
|
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.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
@@ -205,7 +251,9 @@ void main() {
|
|||||||
expect(feedProbe.loadRequests, 1);
|
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
|
// Arrange
|
||||||
Widget deleteFlowWidget = Column(
|
Widget deleteFlowWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -230,35 +278,44 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// 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(blogPostRepository.deleteCalls.single, deleteFixture['id']);
|
||||||
expect(feedProbe.loadRequests, 1);
|
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
|
// Arrange
|
||||||
blogPostRepository.createError = Exception('Network error');
|
blogPostRepository.createError = Exception('Network error');
|
||||||
|
|
||||||
Widget errorFlowWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget errorFlowWidget =
|
||||||
builder: (context, state) {
|
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateError) {
|
builder: (context, state) {
|
||||||
return Column(
|
if (state.errorMessage != null) {
|
||||||
children: [
|
return Column(
|
||||||
Text('Error: ${state.errorMessage}'),
|
children: [
|
||||||
const SizedBox(height: 16),
|
Text('Error: ${state.errorMessage}'),
|
||||||
ElevatedButton(
|
const SizedBox(height: 16),
|
||||||
key: const Key('retryButton'),
|
ElevatedButton(
|
||||||
onPressed: () async {
|
key: const Key('retryButton'),
|
||||||
await blogPostCreateCubit.createBlogPost(title: 'Retry Title', videoUrl: null, description: 'Retry Description', image: null);
|
onPressed: () async {
|
||||||
},
|
await blogPostCreateCubit.createBlogPost(
|
||||||
child: const Text('Повторить'),
|
title: 'Retry Title',
|
||||||
),
|
videoUrl: null,
|
||||||
],
|
description: 'Retry Description',
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
return const Text('No Error');
|
child: const Text('Повторить'),
|
||||||
},
|
),
|
||||||
);
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Text('No Error');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(errorFlowWidget));
|
await tester.pumpWidget(createTestWidget(errorFlowWidget));
|
||||||
@@ -269,7 +326,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
@@ -278,18 +334,27 @@ void main() {
|
|||||||
expect(find.byKey(const Key('retryButton')), findsOneWidget);
|
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
|
// Arrange
|
||||||
blogPostRepository.createDelay = const Duration(milliseconds: 50);
|
blogPostRepository.createDelay = const Duration(milliseconds: 50);
|
||||||
|
|
||||||
Widget loadingFlowWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget loadingFlowWidget =
|
||||||
builder: (context, state) {
|
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateLoading) {
|
builder: (context, state) {
|
||||||
return const Column(children: [CircularProgressIndicator(), SizedBox(height: 16), Text('Создание блог поста...')]);
|
if (state.isLoading) {
|
||||||
}
|
return const Column(
|
||||||
return const Text('Not Loading');
|
children: [
|
||||||
},
|
CircularProgressIndicator(),
|
||||||
);
|
SizedBox(height: 16),
|
||||||
|
Text('Создание блог поста...'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Text('Not Loading');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(loadingFlowWidget));
|
await tester.pumpWidget(createTestWidget(loadingFlowWidget));
|
||||||
@@ -300,7 +365,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(); // Need to pump twice to get the loading state
|
await tester.pump(); // Need to pump twice to get the loading state
|
||||||
@@ -314,29 +378,32 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
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
|
// Arrange
|
||||||
Widget successFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget successFlowWidget =
|
||||||
listener: (context, state) {
|
BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
listener: (context, state) {
|
||||||
feedProbe.loadFeedBlogPostList();
|
if (state.isSuccess) {
|
||||||
}
|
feedProbe.loadFeedBlogPostList();
|
||||||
},
|
}
|
||||||
child: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
},
|
||||||
builder: (context, state) {
|
child: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
builder: (context, state) {
|
||||||
return const Column(
|
if (state.isSuccess) {
|
||||||
children: [
|
return const Column(
|
||||||
Icon(Icons.check_circle, color: Colors.green, size: 48),
|
children: [
|
||||||
SizedBox(height: 16),
|
Icon(Icons.check_circle, color: Colors.green, size: 48),
|
||||||
Text('Блог пост успешно создан!'),
|
SizedBox(height: 16),
|
||||||
],
|
Text('Блог пост успешно создан!'),
|
||||||
);
|
],
|
||||||
}
|
);
|
||||||
return const Text('Not Loaded');
|
}
|
||||||
},
|
return const Text('Not Loaded');
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(successFlowWidget));
|
await tester.pumpWidget(createTestWidget(successFlowWidget));
|
||||||
@@ -347,7 +414,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
@@ -357,28 +423,43 @@ void main() {
|
|||||||
expect(feedProbe.loadRequests, 1);
|
expect(feedProbe.loadRequests, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle form validation flow', (WidgetTester tester) async {
|
testWidgets('should handle form validation flow', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget validationFlowWidget = Column(
|
Widget validationFlowWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок обязателен'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
errorText: 'Заголовок обязателен',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание обязательно'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Описание',
|
||||||
|
errorText: 'Описание обязательно',
|
||||||
|
),
|
||||||
maxLines: 6,
|
maxLines: 6,
|
||||||
minLines: 4,
|
minLines: 4,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Ссылка на видео', errorText: 'Введите корректную ссылку'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Ссылка на видео',
|
||||||
|
errorText: 'Введите корректную ссылку',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
Icon(
|
||||||
|
Icons.add_photo_alternate,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text('Добавить изображение'),
|
Text('Добавить изображение'),
|
||||||
],
|
],
|
||||||
@@ -431,7 +516,6 @@ void main() {
|
|||||||
title: 'Test Title',
|
title: 'Test Title',
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: 'Test Description',
|
description: 'Test Description',
|
||||||
image: null, // В реальном тесте здесь был бы XFile
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Опубликовать'),
|
child: const Text('Опубликовать'),
|
||||||
@@ -450,90 +534,122 @@ void main() {
|
|||||||
expect(imageUploadInitiated, isTrue);
|
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
|
// Arrange
|
||||||
Widget completeFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget completeFlowWidget =
|
||||||
listener: (context, state) {
|
BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
listener: (context, state) {
|
||||||
feedProbe.loadFeedBlogPostList();
|
if (state.isSuccess) {
|
||||||
}
|
feedProbe.loadFeedBlogPostList();
|
||||||
},
|
}
|
||||||
child: Column(
|
},
|
||||||
children: [
|
child: Column(
|
||||||
const Text('Заголовок'),
|
children: [
|
||||||
const SizedBox(height: 8),
|
const Text('Заголовок'),
|
||||||
TextFormField(
|
const SizedBox(height: 8),
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
TextFormField(
|
||||||
decoration: const InputDecoration(hintText: 'Введите заголовок поста'),
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
const SizedBox(height: 16),
|
hintText: 'Введите заголовок поста',
|
||||||
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.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
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(completeFlowWidget));
|
await tester.pumpWidget(createTestWidget(completeFlowWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String);
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String);
|
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.pump();
|
||||||
|
|
||||||
// Scroll to make sure the button is visible
|
// 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.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(blogPostRepository.createPayloads.single.toJson(), fullCreateFixture);
|
expect(
|
||||||
|
blogPostRepository.createPayloads.single.toJson(),
|
||||||
|
fullCreateFixture,
|
||||||
|
);
|
||||||
expect(feedProbe.loadRequests, 1);
|
expect(feedProbe.loadRequests, 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/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/diets_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_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_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.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_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:mockito/annotations.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:mockito/mockito.dart';
|
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.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 '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() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
@@ -37,13 +39,22 @@ void main() {
|
|||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
await sl.reset();
|
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);
|
AppConfigRegistration.registerAppConfig(buildType: AppBuildType.tests);
|
||||||
|
registerFallbackValue(<XFile>[]);
|
||||||
|
registerFallbackValue(<int>[]);
|
||||||
mockBlogPostRepository = MockBlogPostRepository();
|
mockBlogPostRepository = MockBlogPostRepository();
|
||||||
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: mockBlogPostRepository);
|
blogPostCreateCubit = BlogPostCreateCubit(
|
||||||
|
blogPostRepository: mockBlogPostRepository,
|
||||||
|
);
|
||||||
mockFeedCubit = MockFeedCubit();
|
mockFeedCubit = MockFeedCubit();
|
||||||
when(mockFeedCubit.state).thenReturn(const FeedState.loading());
|
when(() => mockFeedCubit.state).thenReturn(const FeedState.loading());
|
||||||
when(mockFeedCubit.stream).thenAnswer((_) => Stream.value(const FeedState.loading()));
|
when(
|
||||||
|
() => mockFeedCubit.stream,
|
||||||
|
).thenAnswer((_) => Stream.value(const FeedState.loading()));
|
||||||
|
when(() => mockFeedCubit.loadFeedBlogPostList()).thenAnswer((_) async {});
|
||||||
sl.registerLazySingleton<FeedCubit>(() => mockFeedCubit);
|
sl.registerLazySingleton<FeedCubit>(() => mockFeedCubit);
|
||||||
sl.registerLazySingleton<BlogPostCreateCubit>(() => blogPostCreateCubit);
|
sl.registerLazySingleton<BlogPostCreateCubit>(() => blogPostCreateCubit);
|
||||||
});
|
});
|
||||||
@@ -52,19 +63,25 @@ void main() {
|
|||||||
blogPostCreateCubit.close();
|
blogPostCreateCubit.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget createTestWidget({FeedBlogPostModel? feedBlogPostModel, bool canPop = false}) {
|
Widget createTestWidget({
|
||||||
|
FeedBlogPostModel? feedBlogPostModel,
|
||||||
|
bool canPop = false,
|
||||||
|
}) {
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
initialLocation: '/blog-post-create',
|
initialLocation: '/blog-post-create',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/home',
|
path: '/home',
|
||||||
builder: (context, state) => const Scaffold(body: Center(child: Text('Home'))),
|
builder: (context, state) =>
|
||||||
|
const Scaffold(body: Center(child: Text('Home'))),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/blog-post-create',
|
path: '/blog-post-create',
|
||||||
builder: (context, state) => MultiBlocProvider(
|
builder: (context, state) => MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
BlocProvider<BlogPostCreateCubit>.value(value: blogPostCreateCubit),
|
BlocProvider<BlogPostCreateCubit>.value(
|
||||||
|
value: blogPostCreateCubit,
|
||||||
|
),
|
||||||
BlocProvider<FeedCubit>.value(value: mockFeedCubit),
|
BlocProvider<FeedCubit>.value(value: mockFeedCubit),
|
||||||
],
|
],
|
||||||
child: BlogPostCreateScreen(feedBlogPostModel: feedBlogPostModel),
|
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.pumpWidget(createTestWidget());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Заголовок'), findsOneWidget);
|
expect(find.text('Заголовок'), findsOneWidget);
|
||||||
expect(find.text('Описание'), findsOneWidget);
|
expect(find.text('Описание'), findsOneWidget);
|
||||||
expect(find.text('Изображение поста'), findsOneWidget);
|
expect(find.textContaining('Изображения'), findsOneWidget);
|
||||||
expect(find.text('Ссылка на видео'), findsOneWidget);
|
expect(find.text('Ссылка на видео'), findsOneWidget);
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget);
|
expect(
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget);
|
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(
|
final existingPost = FeedBlogPostModel(
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'Test Title',
|
title: 'Test Title',
|
||||||
description: 'Test Description',
|
description: 'Test Description',
|
||||||
videoUrl: 'https://youtube.com/watch?v=test',
|
videoUrl: 'https://rutube.ru/video/12345/',
|
||||||
image: '', // Changed: removed image so video field is visible
|
image: '',
|
||||||
state: 'published',
|
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(),
|
createdAt: DateTime.now(),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
totalLikes: 0,
|
totalLikes: 0,
|
||||||
@@ -117,24 +152,299 @@ void main() {
|
|||||||
wasLikedByMe: false,
|
wasLikedByMe: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost));
|
await tester.pumpWidget(
|
||||||
|
createTestWidget(feedBlogPostModel: existingPost),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final titleField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenTitleTextField));
|
final titleField = tester.widget<CustomTextFormField>(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
|
);
|
||||||
expect(titleField.controller?.text, 'Test Title');
|
expect(titleField.controller?.text, 'Test Title');
|
||||||
|
|
||||||
final descriptionField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenContentTextField));
|
final descriptionField = tester.widget<CustomTextFormField>(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
);
|
||||||
expect(descriptionField.controller?.text, 'Test Description');
|
expect(descriptionField.controller?.text, 'Test Description');
|
||||||
|
|
||||||
final videoFieldFinder = find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField);
|
final videoField = tester.widget<CustomTextFormField>(
|
||||||
if (videoFieldFinder.evaluate().isNotEmpty) {
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
final videoField = tester.widget<CustomTextFormField>(videoFieldFinder);
|
);
|
||||||
expect(videoField.controller?.text, 'https://youtube.com/watch?v=test');
|
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 {
|
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<CustomTextFormField>(
|
||||||
|
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<CustomTextFormField>(
|
||||||
|
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<CustomTextFormField>(
|
||||||
|
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(
|
final existingPost = FeedBlogPostModel(
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'Test Title',
|
title: 'Test Title',
|
||||||
@@ -154,136 +464,140 @@ void main() {
|
|||||||
totalViews: 0,
|
totalViews: 0,
|
||||||
totalComments: 0,
|
totalComments: 0,
|
||||||
wasLikedByMe: false,
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Ссылка на видео'), findsNothing);
|
await tester.enterText(
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsNothing);
|
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 {
|
testWidgets('does not submit update when form content is cleared', (
|
||||||
final existingPost = FeedBlogPostModel(
|
tester,
|
||||||
id: 1,
|
) async {
|
||||||
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<CustomTextFormField>(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<CustomTextFormField>(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<CustomTextFormField>(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 {
|
|
||||||
await mockNetworkImagesFor(() 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;
|
return;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -306,26 +620,46 @@ void main() {
|
|||||||
totalViews: 0,
|
totalViews: 0,
|
||||||
totalComments: 0,
|
totalComments: 0,
|
||||||
wasLikedByMe: false,
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final titleField = find.byKey(AppKeys.blogPostCreateScreenTitleTextField);
|
final titleField = find.byKey(
|
||||||
|
AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
|
);
|
||||||
expect(titleField, findsOneWidget);
|
expect(titleField, findsOneWidget);
|
||||||
|
|
||||||
await tester.enterText(titleField, 'Updated Title');
|
await tester.enterText(titleField, 'Updated Title');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final createButton = find.text('Создать');
|
final saveButton = find.text('Сохранить');
|
||||||
await tester.ensureVisible(createButton);
|
await tester.ensureVisible(saveButton);
|
||||||
await tester.tap(createButton, warnIfMissed: false);
|
await tester.tap(saveButton, warnIfMissed: false);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.pump(const Duration(milliseconds: 200));
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
await tester.pumpAndSettle();
|
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 {
|
testWidgets('renders error state', (tester) async {
|
||||||
|
blogPostCreateCubit.emit(
|
||||||
blogPostCreateCubit.emit(const BlogPostCreateState.error(errorMessage: 'Error creating post'));
|
const BlogPostCreateState(errorMessage: 'Error creating post'),
|
||||||
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -354,7 +689,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders loading state', (tester) async {
|
testWidgets('renders loading state', (tester) async {
|
||||||
blogPostCreateCubit.emit(const BlogPostCreateState.loading());
|
blogPostCreateCubit.emit(const BlogPostCreateState(isLoading: true));
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -25,204 +25,172 @@ void main() {
|
|||||||
test('deleteBlogPost вызывает сервис', () async {
|
test('deleteBlogPost вызывает сервис', () async {
|
||||||
const id = 1;
|
const id = 1;
|
||||||
|
|
||||||
when(() => mockService.deleteBlogPost(id: id)).thenAnswer((_) async => {});
|
when(
|
||||||
|
() => mockService.deleteBlogPost(id: id),
|
||||||
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
await repository.deleteBlogPost(id);
|
await repository.deleteBlogPost(id);
|
||||||
|
|
||||||
verify(() => mockService.deleteBlogPost(id: id)).called(1);
|
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 {
|
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(
|
when(
|
||||||
() => mockService.updateBlogPost(
|
() => mockService.createBlogPost(formData: any(named: 'formData')),
|
||||||
id: id,
|
|
||||||
formData: any(named: 'formData'),
|
|
||||||
),
|
|
||||||
).thenAnswer((_) async => {});
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
await repository.updateBlogPost(id, title, null, null, null, null);
|
await repository.createBlogPost(
|
||||||
|
title: 'Test Title',
|
||||||
|
description: 'Test Description',
|
||||||
|
);
|
||||||
|
|
||||||
verify(
|
final captured =
|
||||||
() => mockService.updateBlogPost(
|
verify(
|
||||||
id: id,
|
() => mockService.createBlogPost(
|
||||||
formData: any(named: 'formData'),
|
formData: captureAny(named: 'formData'),
|
||||||
),
|
),
|
||||||
).called(1);
|
).captured.single
|
||||||
});
|
as FormData;
|
||||||
|
|
||||||
test('createBlogPost - обрабатывает пустые поля', () async {
|
expect(
|
||||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
captured.fields.map((e) => '${e.key}=${e.value}'),
|
||||||
|
containsAll(['title=Test Title', 'description_md=Test Description']),
|
||||||
await repository.createBlogPost(null, null, null, null);
|
);
|
||||||
|
expect(captured.files, isEmpty);
|
||||||
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 {
|
test('createBlogPost - обрабатывает только videoUrl', () async {
|
||||||
const videoUrl = 'https://example.com/video.mp4';
|
const videoUrl = 'https://rutube.ru/video/12345/';
|
||||||
|
|
||||||
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(
|
when(
|
||||||
() => mockService.updateBlogPost(
|
() => mockService.createBlogPost(formData: any(named: 'formData')),
|
||||||
id: id,
|
|
||||||
formData: any(named: 'formData'),
|
|
||||||
),
|
|
||||||
).thenAnswer((_) async => {});
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
await repository.updateBlogPost(id, title, videoUrl, description, null, null);
|
await repository.createBlogPost(videoUrl: videoUrl);
|
||||||
|
|
||||||
verify(
|
final captured =
|
||||||
() => mockService.updateBlogPost(
|
verify(
|
||||||
id: id,
|
() => mockService.createBlogPost(
|
||||||
formData: any(named: 'formData'),
|
formData: captureAny(named: 'formData'),
|
||||||
),
|
),
|
||||||
).called(1);
|
).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 {
|
test('updateBlogPost - обрабатывает пустые поля', () async {
|
||||||
const id = 1;
|
|
||||||
|
|
||||||
when(
|
when(
|
||||||
() => mockService.updateBlogPost(
|
() =>
|
||||||
id: id,
|
mockService.updateBlogPost(id: 1, formData: any(named: 'formData')),
|
||||||
formData: any(named: 'formData'),
|
|
||||||
),
|
|
||||||
).thenAnswer((_) async => {});
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
await repository.updateBlogPost(id, null, null, null, null, null);
|
await repository.updateBlogPost(id: 1);
|
||||||
|
|
||||||
verify(
|
verify(
|
||||||
() => mockService.updateBlogPost(
|
() =>
|
||||||
id: id,
|
mockService.updateBlogPost(id: 1, formData: any(named: 'formData')),
|
||||||
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'),
|
|
||||||
),
|
|
||||||
).called(1);
|
).called(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ void main() {
|
|||||||
test('createBlogPost - создает пост блога', () async {
|
test('createBlogPost - создает пост блога', () async {
|
||||||
final formData = FormData.fromMap({});
|
final formData = FormData.fromMap({});
|
||||||
|
|
||||||
when(mockBlogPostService.createBlogPost(formData: formData))
|
when(
|
||||||
.thenAnswer((_) async => Future.value());
|
mockBlogPostService.createBlogPost(formData: formData),
|
||||||
|
).thenAnswer((_) async => Future.value());
|
||||||
|
|
||||||
await mockBlogPostService.createBlogPost(formData: formData);
|
await mockBlogPostService.createBlogPost(formData: formData);
|
||||||
|
|
||||||
@@ -30,19 +31,35 @@ void main() {
|
|||||||
const id = 1;
|
const id = 1;
|
||||||
final formData = FormData.fromMap({});
|
final formData = FormData.fromMap({});
|
||||||
|
|
||||||
when(mockBlogPostService.updateBlogPost(id: id, formData: formData))
|
when(
|
||||||
.thenAnswer((_) async => Future.value());
|
mockBlogPostService.updateBlogPost(id: id, formData: formData),
|
||||||
|
).thenAnswer((_) async => Future.value());
|
||||||
|
|
||||||
await mockBlogPostService.updateBlogPost(id: id, formData: formData);
|
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 {
|
test('deleteBlogPost - удаляет пост блога', () async {
|
||||||
const id = 1;
|
const id = 1;
|
||||||
|
|
||||||
when(mockBlogPostService.deleteBlogPost(id: id))
|
when(
|
||||||
.thenAnswer((_) async => Future.value());
|
mockBlogPostService.deleteBlogPost(id: id),
|
||||||
|
).thenAnswer((_) async => Future.value());
|
||||||
|
|
||||||
await mockBlogPostService.deleteBlogPost(id: id);
|
await mockBlogPostService.deleteBlogPost(id: id);
|
||||||
|
|
||||||
@@ -50,4 +67,3 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -158,6 +158,10 @@ void main() {
|
|||||||
expect(valid.hasValidPassportData, isTrue);
|
expect(valid.hasValidPassportData, isTrue);
|
||||||
expect(valid.copyWith(inn: '123').hasValidPassportData, isFalse);
|
expect(valid.copyWith(inn: '123').hasValidPassportData, isFalse);
|
||||||
expect(valid.copyWith(lastName: '').hasValidPassportData, isFalse);
|
expect(valid.copyWith(lastName: '').hasValidPassportData, isFalse);
|
||||||
|
expect(
|
||||||
|
valid.copyWith(birthDate: '2020-02-31').hasValidPassportData,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -463,7 +463,30 @@ void main() {
|
|||||||
expect(cubit.state.isScoringPolling, isFalse);
|
expect(cubit.state.isScoringPolling, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registerConsultant blocks submit without passport data', () async {
|
test(
|
||||||
|
'retryScoring writes fieldErrors when passport data is invalid',
|
||||||
|
() async {
|
||||||
|
cubit.emit(
|
||||||
|
cubit.state.copyWith(
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||||
|
loading: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await cubit.retryScoring();
|
||||||
|
|
||||||
|
expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН');
|
||||||
|
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed);
|
||||||
|
verifyNever(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
|
update: any<bool>(named: 'update'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('registerConsultant writes fieldErrors without calling API', () async {
|
||||||
when(
|
when(
|
||||||
() => repository.registerConsultant(
|
() => repository.registerConsultant(
|
||||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
@@ -473,7 +496,10 @@ void main() {
|
|||||||
|
|
||||||
await cubit.registerConsultant();
|
await cubit.registerConsultant();
|
||||||
|
|
||||||
expect(cubit.state.error, 'Заполните паспортные данные');
|
expect(cubit.state.fieldErrors['last_name'], 'Укажите фамилию');
|
||||||
|
expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН');
|
||||||
|
expect(cubit.state.fieldErrors['birth_date'], 'Укажите дату рождения');
|
||||||
|
expect(cubit.state.error, isNull);
|
||||||
verifyNever(
|
verifyNever(
|
||||||
() => repository.registerConsultant(
|
() => repository.registerConsultant(
|
||||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
@@ -482,6 +508,19 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('editing a field clears only that field error', () async {
|
||||||
|
await cubit.registerConsultant();
|
||||||
|
expect(cubit.state.fieldErrors.containsKey('last_name'), isTrue);
|
||||||
|
expect(cubit.state.fieldErrors.containsKey('inn'), isTrue);
|
||||||
|
|
||||||
|
cubit.updateConsultantProfileModel(
|
||||||
|
cubit.state.consultant.copyWith(lastName: 'Иванов'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cubit.state.fieldErrors.containsKey('last_name'), isFalse);
|
||||||
|
expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН');
|
||||||
|
});
|
||||||
|
|
||||||
test('registerConsultant maps Laravel 422 into fieldErrors', () async {
|
test('registerConsultant maps Laravel 422 into fieldErrors', () async {
|
||||||
when(
|
when(
|
||||||
() => repository.registerConsultant(
|
() => repository.registerConsultant(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl
|
|||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -60,5 +61,19 @@ void main() {
|
|||||||
expect(find.text('Банк получатель (поиск)'), findsOneWidget);
|
expect(find.text('Банк получатель (поиск)'), findsOneWidget);
|
||||||
expect(find.text('Я являюсь налоговым резидентом США'), findsOneWidget);
|
expect(find.text('Я являюсь налоговым резидентом США'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('shows ValidationErrorWidget for invalid field', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
cubit.emit(
|
||||||
|
cubit.state.copyWith(fieldErrors: const {'inn': 'Некорректный ИНН'}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(const PassportDataScreen()));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(ValidationErrorWidget), findsOneWidget);
|
||||||
|
expect(find.text('Некорректный ИНН'), findsOneWidget);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const valid = ConsultantProfileModel(
|
||||||
|
lastName: 'Иванов',
|
||||||
|
firstName: 'Иван',
|
||||||
|
inn: '123456789012',
|
||||||
|
snils: '123-456-789 00',
|
||||||
|
passportSeries: '1234',
|
||||||
|
passportNumber: '567890',
|
||||||
|
passportIssueDate: '2020-01-15',
|
||||||
|
passportIssuer: 'ОВД',
|
||||||
|
passportIssuerCode: '772-053',
|
||||||
|
birthDate: '1990-05-20',
|
||||||
|
birthPlace: 'Москва',
|
||||||
|
registrationAddress: 'Адрес 1',
|
||||||
|
residenceAddress: 'Адрес 2',
|
||||||
|
citizenship: 'RU',
|
||||||
|
);
|
||||||
|
|
||||||
|
group('PassportDataValidator', () {
|
||||||
|
test('returns empty map for a valid individual person profile', () {
|
||||||
|
expect(PassportDataValidator.validate(valid), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires empty text fields', () {
|
||||||
|
final errors = PassportDataValidator.validate(
|
||||||
|
valid.copyWith(lastName: '', firstName: null),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors['last_name'], 'Укажите фамилию');
|
||||||
|
expect(errors['first_name'], 'Укажите имя');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects short INN and malformed SNILS', () {
|
||||||
|
final errors = PassportDataValidator.validate(
|
||||||
|
valid.copyWith(inn: '123', snils: '12345678900'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors['inn'], 'Некорректный ИНН');
|
||||||
|
expect(errors['snils'], 'Некорректный СНИЛС');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects empty INN and SNILS', () {
|
||||||
|
final errors = PassportDataValidator.validate(
|
||||||
|
valid.copyWith(inn: '', snils: ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors['inn'], 'Укажите ИНН');
|
||||||
|
expect(errors['snils'], 'Укажите СНИЛС');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-calendar dates', () {
|
||||||
|
final errors = PassportDataValidator.validate(
|
||||||
|
valid.copyWith(
|
||||||
|
birthDate: '2020-02-31',
|
||||||
|
passportIssueDate: '1999-13-32',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors['birth_date'], 'Некорректная дата рождения');
|
||||||
|
expect(errors['passport_issue_date'], 'Некорректная дата выдачи');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects dates in the future', () {
|
||||||
|
final errors = PassportDataValidator.validate(
|
||||||
|
valid.copyWith(
|
||||||
|
birthDate: '2030-01-01',
|
||||||
|
passportIssueDate: '2030-01-01',
|
||||||
|
),
|
||||||
|
now: DateTime(2026, 8, 13),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors['birth_date'], PassportDataValidator.futureDateMessage);
|
||||||
|
expect(
|
||||||
|
errors['passport_issue_date'],
|
||||||
|
PassportDataValidator.futureDateMessage,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts today as a valid date', () {
|
||||||
|
final errors = PassportDataValidator.validate(
|
||||||
|
valid.copyWith(passportIssueDate: '2026-08-13'),
|
||||||
|
now: DateTime(2026, 8, 13, 18),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errors.containsKey('passport_issue_date'), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -47,13 +47,77 @@ class _FakeHttpClientRequest implements HttpClientRequest {
|
|||||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeHttpClientResponse extends Stream<List<int>> implements HttpClientResponse {
|
class _FakeHttpClientResponse extends Stream<List<int>>
|
||||||
|
implements HttpClientResponse {
|
||||||
// 1x1 transparent PNG.
|
// 1x1 transparent PNG.
|
||||||
static final List<int> _png = <int>[
|
static final List<int> _png = <int>[
|
||||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01,
|
0x89,
|
||||||
0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41,
|
0x50,
|
||||||
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
|
0x4E,
|
||||||
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
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
|
@override
|
||||||
@@ -63,11 +127,22 @@ class _FakeHttpClientResponse extends Stream<List<int>> implements HttpClientRes
|
|||||||
int get contentLength => _png.length;
|
int get contentLength => _png.length;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
HttpClientResponseCompressionState get compressionState => HttpClientResponseCompressionState.notCompressed;
|
HttpClientResponseCompressionState get compressionState =>
|
||||||
|
HttpClientResponseCompressionState.notCompressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
StreamSubscription<List<int>> listen(void Function(List<int>)? onData, {Function? onError, void Function()? onDone, bool? cancelOnError}) {
|
StreamSubscription<List<int>> listen(
|
||||||
return Stream<List<int>>.fromIterable([_png]).listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError);
|
void Function(List<int>)? onData, {
|
||||||
|
Function? onError,
|
||||||
|
void Function()? onDone,
|
||||||
|
bool? cancelOnError,
|
||||||
|
}) {
|
||||||
|
return Stream<List<int>>.fromIterable([_png]).listen(
|
||||||
|
onData,
|
||||||
|
onError: onError,
|
||||||
|
onDone: onDone,
|
||||||
|
cancelOnError: cancelOnError,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -78,12 +153,18 @@ class _FakeHttpClientResponse extends Stream<List<int>> implements HttpClientRes
|
|||||||
void main() {
|
void main() {
|
||||||
group('ProfileBlogPostWidget smoke', () {
|
group('ProfileBlogPostWidget smoke', () {
|
||||||
setUpAll(() async {
|
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 {
|
setUp(() async {
|
||||||
await sl.reset();
|
await sl.reset();
|
||||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
sl.registerLazySingleton<AppConfig>(
|
||||||
|
() =>
|
||||||
|
AppConfig(buildType: AppBuildType.tests)
|
||||||
|
..setBaseUrl('http://localhost'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const profile = ProfileModel(
|
const profile = ProfileModel(
|
||||||
@@ -160,7 +241,13 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('renders gallery and video media content', (tester) async {
|
testWidgets('renders gallery and video media content', (tester) async {
|
||||||
final post = FeedFixtures.feedBlogPostGalleryAndVideo();
|
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();
|
final cubit = MockProfileCubit();
|
||||||
when(cubit.state).thenReturn(loadedState);
|
when(cubit.state).thenReturn(loadedState);
|
||||||
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
||||||
@@ -174,7 +261,13 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('renders gallery-only media content', (tester) async {
|
testWidgets('renders gallery-only media content', (tester) async {
|
||||||
final post = FeedFixtures.feedBlogPostWithGallery();
|
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();
|
final cubit = MockProfileCubit();
|
||||||
when(cubit.state).thenReturn(loadedState);
|
when(cubit.state).thenReturn(loadedState);
|
||||||
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
||||||
@@ -188,7 +281,13 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('renders video-only media content', (tester) async {
|
testWidgets('renders video-only media content', (tester) async {
|
||||||
final post = FeedFixtures.feedBlogPostVideoOnly();
|
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();
|
final cubit = MockProfileCubit();
|
||||||
when(cubit.state).thenReturn(loadedState);
|
when(cubit.state).thenReturn(loadedState);
|
||||||
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
||||||
@@ -199,5 +298,84 @@ void main() {
|
|||||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||||
expect(find.text('Видео'), 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<ProfileState>.empty());
|
||||||
|
when(cubit.close()).thenAnswer((_) async => Future<void>.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<Hero>(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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Complete Test Title",
|
"title": "Complete Test Title",
|
||||||
"description_md": "Complete Test Description",
|
"description_md": "Complete Test Description",
|
||||||
"video_url": "https://youtube.com/watch?v=complete",
|
"video_url": "https://rutube.ru/video/12345/",
|
||||||
"image_name": null
|
"image_name": null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"title": "Updated Title",
|
"title": "Updated Title",
|
||||||
"description_md": "Updated Description",
|
"description_md": "Updated Description",
|
||||||
"video_url": "https://youtube.com/watch?v=updated",
|
"video_url": "https://vk.com/video-1_1",
|
||||||
"old_image_url": "original_image.jpg",
|
"existing_media_ids": [2, 1],
|
||||||
"image_name": null
|
"image_name": null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
test/fixtures/doubles/blog_post_doubles.dart
vendored
43
test/fixtures/doubles/blog_post_doubles.dart
vendored
@@ -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';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
|
||||||
class FixtureBlogPostRepository extends BlogPostRepository {
|
class FixtureBlogPostRepository extends BlogPostRepository {
|
||||||
FixtureBlogPostRepository()
|
FixtureBlogPostRepository() : super(blogPostService: _NoopBlogPostService());
|
||||||
: super(blogPostService: _NoopBlogPostService());
|
|
||||||
|
|
||||||
final List<BlogPostCreatePayload> createPayloads = <BlogPostCreatePayload>[];
|
final List<BlogPostCreatePayload> createPayloads = <BlogPostCreatePayload>[];
|
||||||
final List<BlogPostUpdatePayload> updatePayloads = <BlogPostUpdatePayload>[];
|
final List<BlogPostUpdatePayload> updatePayloads = <BlogPostUpdatePayload>[];
|
||||||
final List<int> deleteCalls = <int>[];
|
final List<int> deleteCalls = <int>[];
|
||||||
|
final List<({int postId, int mediaId})> deleteMediaCalls = <({int postId, int mediaId})>[];
|
||||||
|
|
||||||
Duration createDelay = Duration.zero;
|
Duration createDelay = Duration.zero;
|
||||||
Duration updateDelay = Duration.zero;
|
Duration updateDelay = Duration.zero;
|
||||||
@@ -18,15 +18,21 @@ class FixtureBlogPostRepository extends BlogPostRepository {
|
|||||||
Object? createError;
|
Object? createError;
|
||||||
Object? updateError;
|
Object? updateError;
|
||||||
Object? deleteError;
|
Object? deleteError;
|
||||||
|
Object? deleteMediaError;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> createBlogPost(String? title, String? videoUrl, String? description, XFile? image) async {
|
Future<void> createBlogPost({
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<XFile> images = const [],
|
||||||
|
}) async {
|
||||||
createPayloads.add(
|
createPayloads.add(
|
||||||
BlogPostCreatePayload(
|
BlogPostCreatePayload(
|
||||||
title: title,
|
title: title,
|
||||||
description: description,
|
description: description,
|
||||||
videoUrl: videoUrl,
|
videoUrl: videoUrl,
|
||||||
imageName: image?.name,
|
imageName: images.isEmpty ? null : images.first.name,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (createDelay > Duration.zero) {
|
if (createDelay > Duration.zero) {
|
||||||
@@ -38,15 +44,26 @@ class FixtureBlogPostRepository extends BlogPostRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updateBlogPost(int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl) async {
|
Future<void> updateBlogPost({
|
||||||
|
required int id,
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<int> existingMediaIds = const [],
|
||||||
|
List<XFile> newImages = const [],
|
||||||
|
List<int> mediaIdsToDelete = const [],
|
||||||
|
}) async {
|
||||||
|
for (final mediaId in mediaIdsToDelete) {
|
||||||
|
deleteMediaCalls.add((postId: id, mediaId: mediaId));
|
||||||
|
}
|
||||||
updatePayloads.add(
|
updatePayloads.add(
|
||||||
BlogPostUpdatePayload(
|
BlogPostUpdatePayload(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
description: description,
|
description: description,
|
||||||
videoUrl: videoUrl,
|
videoUrl: videoUrl,
|
||||||
imageName: image?.name,
|
imageName: newImages.isEmpty ? null : newImages.first.name,
|
||||||
oldImageUrl: oldImageUrl,
|
existingMediaIds: existingMediaIds,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (updateDelay > Duration.zero) {
|
if (updateDelay > Duration.zero) {
|
||||||
@@ -97,7 +114,7 @@ class BlogPostUpdatePayload {
|
|||||||
required this.description,
|
required this.description,
|
||||||
required this.videoUrl,
|
required this.videoUrl,
|
||||||
required this.imageName,
|
required this.imageName,
|
||||||
required this.oldImageUrl,
|
required this.existingMediaIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int id;
|
final int id;
|
||||||
@@ -105,15 +122,15 @@ class BlogPostUpdatePayload {
|
|||||||
final String? description;
|
final String? description;
|
||||||
final String? videoUrl;
|
final String? videoUrl;
|
||||||
final String? imageName;
|
final String? imageName;
|
||||||
final String? oldImageUrl;
|
final List<int> existingMediaIds;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||||
'id': id,
|
'id': id,
|
||||||
'title': title,
|
'title': title,
|
||||||
'description_md': description,
|
'description_md': description,
|
||||||
'video_url': videoUrl,
|
'video_url': videoUrl,
|
||||||
|
'existing_media_ids': existingMediaIds,
|
||||||
'image_name': imageName,
|
'image_name': imageName,
|
||||||
'old_image_url': oldImageUrl,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +145,11 @@ class _NoopBlogPostService implements BlogPostService {
|
|||||||
throw UnimplementedError('Fixture overrides repository methods directly.');
|
throw UnimplementedError('Fixture overrides repository methods directly.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteBlogPostMedia({required int postId, required int mediaId}) {
|
||||||
|
throw UnimplementedError('Fixture overrides repository methods directly.');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updateBlogPost({required int id, required FormData formData}) {
|
Future<void> updateBlogPost({required int id, required FormData formData}) {
|
||||||
throw UnimplementedError('Fixture overrides repository methods directly.');
|
throw UnimplementedError('Fixture overrides repository methods directly.');
|
||||||
@@ -141,4 +163,3 @@ class FeedReloadProbe {
|
|||||||
loadRequests += 1;
|
loadRequests += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user