forked from eatvio/eatvio_app
Compare commits
58 Commits
consultant
...
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 | |||
| 063da03d7e | |||
| 6b5927a99e | |||
| afab72f7bc | |||
| 2770ff0e80 | |||
| 1200021bb6 | |||
|
|
9e8b8b89fc | ||
| a24d052193 | |||
|
|
9de0efa28e | ||
| 37504aa292 | |||
| c8588a2e1f | |||
| e5086d7fbc | |||
|
|
2d6b042d45 | ||
| d56aa45c92 | |||
|
|
b3d0a26667 | ||
| 8835d65944 | |||
|
|
cc9aa6bffb | ||
| a15e8b78a5 | |||
|
|
0667ab81a8 | ||
|
|
1dc60eec64 | ||
|
|
69f0f1e919 | ||
|
|
a6fe6afb80 | ||
|
|
c8c8ade083 | ||
| 6a17bf3a95 | |||
|
|
2cccc9e253 | ||
| dcf7b25989 | |||
| d6b57ef34d | |||
| 45f20cc903 | |||
| 746b9b6d28 | |||
|
|
2dbd4a455f | ||
| c7514e2cbf | |||
| f1abea70c5 | |||
| e4d397b478 | |||
| 9c5c6df751 | |||
|
|
48a984f5e9 |
@@ -59,12 +59,16 @@ jobs:
|
||||
- name: Flutter build
|
||||
run: |
|
||||
flutter --version
|
||||
flutter config --no-enable-swift-package-manager
|
||||
rm -rf ios/Podfile.lock
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
flutter build ios --config-only --release
|
||||
cd ios
|
||||
pod install
|
||||
pod install --repo-update
|
||||
cd ..
|
||||
flutter build ios --config-only --release
|
||||
cd ios
|
||||
fastlane beta
|
||||
env:
|
||||
LANG: en_US.UTF-8
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,4 +24,5 @@ coverage/
|
||||
backend/
|
||||
.fvm/
|
||||
.fvmrc
|
||||
/eatvio
|
||||
/eatvio
|
||||
*.code-workspace
|
||||
@@ -1,7 +1,7 @@
|
||||
# Current Context
|
||||
|
||||
## Project Status
|
||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production.
|
||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production.
|
||||
|
||||
## Recent Activity
|
||||
- Memory Bank initialization completed
|
||||
@@ -9,10 +9,19 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.
|
||||
- Architecture and technology stack catalogued
|
||||
|
||||
## Current Focus
|
||||
Ready for development tasks. No active work in progress.
|
||||
Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`)
|
||||
мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс
|
||||
fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует
|
||||
Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview`
|
||||
делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play →
|
||||
скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с
|
||||
для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen.
|
||||
Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`.
|
||||
|
||||
## Known Issues
|
||||
None documented at initialization.
|
||||
- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`,
|
||||
`post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие,
|
||||
связаны с fixtures/маппингом, не с видеоплеером.
|
||||
|
||||
## Next Steps
|
||||
Awaiting user direction for:
|
||||
|
||||
@@ -47,8 +47,6 @@ post_install do |installer|
|
||||
## dart: PermissionGroup.photos
|
||||
'PERMISSION_PHOTOS=1',
|
||||
'PERMISSION_NOTIFICATIONS=1',
|
||||
## dart: PermissionGroup.mediaLibrary
|
||||
'PERMISSION_MEDIA_LIBRARY=1',
|
||||
]
|
||||
|
||||
end
|
||||
|
||||
@@ -31,8 +31,6 @@
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSAppleMusicUsageDescription</key>
|
||||
<string>This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
@@ -13,9 +14,11 @@ import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cub
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
@@ -30,6 +33,7 @@ class CustomPopup extends StatelessWidget {
|
||||
final Recipe? recipe;
|
||||
final Diet? diet;
|
||||
final String userSlug;
|
||||
final VoidCallback? onPostsChanged;
|
||||
|
||||
const CustomPopup({
|
||||
super.key,
|
||||
@@ -41,7 +45,8 @@ class CustomPopup extends StatelessWidget {
|
||||
this.feedBlogPostModel,
|
||||
this.recipe,
|
||||
this.diet,
|
||||
required this.userSlug
|
||||
required this.userSlug,
|
||||
this.onPostsChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -103,7 +108,8 @@ class CustomPopup extends StatelessWidget {
|
||||
if (complainedContentType == ComplainedContent.posts) {
|
||||
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
||||
if (complainedContentType == ComplainedContent.posts) {
|
||||
GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||
await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||
onPostsChanged?.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -114,7 +120,14 @@ class CustomPopup extends StatelessWidget {
|
||||
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
|
||||
return;
|
||||
}
|
||||
showSuccessToast('Пост удален, обновите ленту');
|
||||
onPostsChanged?.call();
|
||||
ProfileCubit.myProfileCubit?.reloadBlogPosts();
|
||||
if (context.mounted) {
|
||||
try {
|
||||
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||
} catch (_) {}
|
||||
}
|
||||
showSuccessToast('Пост удален');
|
||||
} else if (complainedContentType == ComplainedContent.recipe) {
|
||||
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
|
||||
if (!result && context.mounted) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
@@ -9,7 +9,10 @@ import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit
|
||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
@@ -33,6 +36,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
||||
final textEditingController = TextEditingController();
|
||||
int? replyId;
|
||||
int currentGalleryIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -40,6 +44,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
@@ -242,8 +250,30 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false)
|
||||
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final post = blogPostState.blogPostModel;
|
||||
if (post == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
return BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'blog-post-screen-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(urls, index, heroTag: urls.length == 1 ? 'blog-post-screen-${post.id}-cover' : null),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
|
||||
@@ -3,38 +3,184 @@ import 'dart:developer';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
|
||||
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
||||
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 {
|
||||
emit(const BlogPostCreateState.loading());
|
||||
await blogPostRepository.createBlogPost(title, videoUrl, description, image);
|
||||
emit(const BlogPostCreateState.loaded());
|
||||
emit(
|
||||
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
|
||||
);
|
||||
await blogPostRepository.createBlogPost(
|
||||
title: trimmedTitle.isEmpty ? null : trimmedTitle,
|
||||
videoUrl: trimmedVideoUrl.isEmpty ? null : trimmedVideoUrl,
|
||||
description: trimmedDescription.isEmpty ? null : trimmedDescription,
|
||||
images: images,
|
||||
);
|
||||
emit(const BlogPostCreateState(isSuccess: true));
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
||||
await reportFrom(error, stackTrace, this);
|
||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
||||
emit(BlogPostCreateState(errorMessage: error.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
emit(const BlogPostCreateState.loading());
|
||||
await blogPostRepository.updateBlogPost(id, title, videoUrl, description, image, oldImageUrl);
|
||||
emit(const BlogPostCreateState.loaded());
|
||||
emit(
|
||||
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
|
||||
);
|
||||
await blogPostRepository.updateBlogPost(
|
||||
id: id,
|
||||
title: trimmedTitle,
|
||||
videoUrl: trimmedVideoUrl,
|
||||
description: trimmedDescription,
|
||||
existingMediaIds: existingMediaIds,
|
||||
newImages: newImages,
|
||||
mediaIdsToDelete: mediaIdsToDelete,
|
||||
);
|
||||
emit(const BlogPostCreateState(isSuccess: true));
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
||||
await reportFrom(error, stackTrace, this);
|
||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
||||
emit(BlogPostCreateState(errorMessage: error.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,23 @@ part 'blog_post_create_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class BlogPostCreateState with _$BlogPostCreateState {
|
||||
const factory BlogPostCreateState() = _BlogPostCreateState;
|
||||
const BlogPostCreateState._();
|
||||
|
||||
const factory BlogPostCreateState.loading() = BlogPostCreateStateLoading;
|
||||
const factory BlogPostCreateState({
|
||||
@Default(false) bool isLoading,
|
||||
@Default(false) bool isSuccess,
|
||||
String? errorMessage,
|
||||
String? titleError,
|
||||
String? descriptionError,
|
||||
String? videoUrlError,
|
||||
String? imagesError,
|
||||
String? formError,
|
||||
}) = _BlogPostCreateState;
|
||||
|
||||
const factory BlogPostCreateState.loaded() = BlogPostCreateStateLoaded;
|
||||
|
||||
const factory BlogPostCreateState.error({
|
||||
required String errorMessage,
|
||||
}) = BlogPostCreateStateError;
|
||||
bool get hasValidationErrors =>
|
||||
titleError != null ||
|
||||
descriptionError != null ||
|
||||
videoUrlError != null ||
|
||||
imagesError != null ||
|
||||
formError != null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
||||
import 'package:eatvio_app/core/theme/app_decoration.dart';
|
||||
@@ -12,8 +12,13 @@ import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -36,20 +41,31 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
final blogPostCubit = sl<BlogPostCreateCubit>();
|
||||
bool shrinkImage = false;
|
||||
late StreamSubscription<bool> keyboardSubscription;
|
||||
XFile? image;
|
||||
final List<FeedGalleryImage> savedImages = [];
|
||||
final List<XFile> pendingImages = [];
|
||||
final List<int> mediaIdsToDelete = [];
|
||||
final titleController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
final videoUrlController = TextEditingController();
|
||||
bool isLoading = false;
|
||||
bool enableMdPanel = false;
|
||||
bool isPreviewMode = false;
|
||||
final FocusNode descriptionFocusNode = FocusNode();
|
||||
|
||||
bool get _isEditMode => widget.feedBlogPostModel != null;
|
||||
|
||||
int get _galleryCount => savedImages.length + pendingImages.length;
|
||||
|
||||
int get _remainingSlots =>
|
||||
BlogPostRepository.maxGalleryImages - _galleryCount;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
blogPostCubit.resetFormErrors();
|
||||
var keyboardVisibilityController = KeyboardVisibilityController();
|
||||
keyboardSubscription = keyboardVisibilityController.onChange.listen((bool visible) {
|
||||
keyboardSubscription = keyboardVisibilityController.onChange.listen((
|
||||
bool visible,
|
||||
) {
|
||||
setState(() {
|
||||
shrinkImage = visible;
|
||||
enableMdPanel = visible;
|
||||
@@ -59,6 +75,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
titleController.text = widget.feedBlogPostModel!.title;
|
||||
descriptionController.text = widget.feedBlogPostModel!.description;
|
||||
videoUrlController.text = widget.feedBlogPostModel!.videoUrl;
|
||||
savedImages.addAll(
|
||||
widget.feedBlogPostModel!.images.where((image) => image.id != null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +85,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
void dispose() {
|
||||
keyboardSubscription.cancel();
|
||||
descriptionFocusNode.dispose();
|
||||
titleController.dispose();
|
||||
descriptionController.dispose();
|
||||
videoUrlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -78,9 +100,16 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
return selectedText.startsWith(prefix) && selectedText.endsWith(suffix);
|
||||
} else {
|
||||
final cursorPosition = selection.baseOffset;
|
||||
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
||||
final beforeCursor = text.substring(cursorPosition - prefix.length, cursorPosition);
|
||||
final afterCursor = text.substring(cursorPosition, cursorPosition + suffix.length);
|
||||
if (cursorPosition >= prefix.length &&
|
||||
text.length >= cursorPosition + suffix.length) {
|
||||
final beforeCursor = text.substring(
|
||||
cursorPosition - prefix.length,
|
||||
cursorPosition,
|
||||
);
|
||||
final afterCursor = text.substring(
|
||||
cursorPosition,
|
||||
cursorPosition + suffix.length,
|
||||
);
|
||||
return beforeCursor == prefix && afterCursor == suffix;
|
||||
}
|
||||
}
|
||||
@@ -93,23 +122,36 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
|
||||
if (selection.isValid && !selection.isCollapsed) {
|
||||
final selectedText = text.substring(selection.start, selection.end);
|
||||
final unformattedText = selectedText.substring(prefix.length, selectedText.length - suffix.length);
|
||||
final unformattedText = selectedText.substring(
|
||||
prefix.length,
|
||||
selectedText.length - suffix.length,
|
||||
);
|
||||
|
||||
final newText = text.replaceRange(selection.start, selection.end, unformattedText);
|
||||
final newText = text.replaceRange(
|
||||
selection.start,
|
||||
selection.end,
|
||||
unformattedText,
|
||||
);
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + unformattedText.length);
|
||||
final newSelection = TextSelection(
|
||||
baseOffset: selection.start,
|
||||
extentOffset: selection.start + unformattedText.length,
|
||||
);
|
||||
descriptionController.selection = newSelection;
|
||||
} else {
|
||||
final cursorPosition = selection.baseOffset;
|
||||
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
|
||||
if (cursorPosition >= prefix.length &&
|
||||
text.length >= cursorPosition + suffix.length) {
|
||||
final beforeCursor = text.substring(0, cursorPosition - prefix.length);
|
||||
final afterCursor = text.substring(cursorPosition + suffix.length);
|
||||
final newText = beforeCursor + afterCursor;
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newCursorPosition = cursorPosition - prefix.length;
|
||||
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
|
||||
descriptionController.selection = TextSelection.collapsed(
|
||||
offset: newCursorPosition,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,18 +164,28 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
final selectedText = text.substring(selection.start, selection.end);
|
||||
final formattedText = '$prefix$selectedText$suffix';
|
||||
|
||||
final newText = text.replaceRange(selection.start, selection.end, formattedText);
|
||||
final newText = text.replaceRange(
|
||||
selection.start,
|
||||
selection.end,
|
||||
formattedText,
|
||||
);
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + formattedText.length);
|
||||
final newSelection = TextSelection(
|
||||
baseOffset: selection.start,
|
||||
extentOffset: selection.start + formattedText.length,
|
||||
);
|
||||
descriptionController.selection = newSelection;
|
||||
} else {
|
||||
final cursorPosition = selection.baseOffset;
|
||||
final newText = '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}';
|
||||
final newText =
|
||||
'${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}';
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newCursorPosition = cursorPosition + prefix.length;
|
||||
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
|
||||
descriptionController.selection = TextSelection.collapsed(
|
||||
offset: newCursorPosition,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,17 +240,31 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
if (linkRegex.hasMatch(selectedText)) {
|
||||
final match = linkRegex.firstMatch(selectedText);
|
||||
final linkText = match?.group(1) ?? selectedText;
|
||||
final newText = text.replaceRange(selection.start, selection.end, linkText);
|
||||
final newText = text.replaceRange(
|
||||
selection.start,
|
||||
selection.end,
|
||||
linkText,
|
||||
);
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + linkText.length);
|
||||
final newSelection = TextSelection(
|
||||
baseOffset: selection.start,
|
||||
extentOffset: selection.start + linkText.length,
|
||||
);
|
||||
descriptionController.selection = newSelection;
|
||||
} else {
|
||||
final formattedText = '[$selectedText](url)';
|
||||
final newText = text.replaceRange(selection.start, selection.end, formattedText);
|
||||
final newText = text.replaceRange(
|
||||
selection.start,
|
||||
selection.end,
|
||||
formattedText,
|
||||
);
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newSelection = TextSelection(baseOffset: selection.start + selectedText.length + 2, extentOffset: selection.start + selectedText.length + 5);
|
||||
final newSelection = TextSelection(
|
||||
baseOffset: selection.start + selectedText.length + 2,
|
||||
extentOffset: selection.start + selectedText.length + 5,
|
||||
);
|
||||
descriptionController.selection = newSelection;
|
||||
}
|
||||
} else {
|
||||
@@ -209,11 +275,14 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
void _applyHorizontalRule() {
|
||||
final cursorPosition = descriptionController.selection.baseOffset;
|
||||
final text = descriptionController.text;
|
||||
final newText = '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}';
|
||||
final newText =
|
||||
'${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}';
|
||||
descriptionController.text = newText;
|
||||
|
||||
final newCursorPosition = cursorPosition + 5;
|
||||
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
|
||||
descriptionController.selection = TextSelection.collapsed(
|
||||
offset: newCursorPosition,
|
||||
);
|
||||
}
|
||||
|
||||
void _togglePreview() {
|
||||
@@ -240,7 +309,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||
),
|
||||
child: SingleChildScrollView(child: _parseMarkdown(descriptionController.text)),
|
||||
child: SingleChildScrollView(
|
||||
child: _parseMarkdown(descriptionController.text),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,7 +319,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
if (text.isEmpty) {
|
||||
return const Text(
|
||||
'Описание',
|
||||
style: TextStyle(fontSize: 16, color: Color(0xFF323F4B), fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF323F4B),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,21 +342,33 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
widgets.add(
|
||||
Text(
|
||||
line.substring(2),
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (line.startsWith('## ')) {
|
||||
widgets.add(
|
||||
Text(
|
||||
line.substring(3),
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (line.startsWith('### ')) {
|
||||
widgets.add(
|
||||
Text(
|
||||
line.substring(4),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
@@ -307,7 +394,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('$number. ', style: const TextStyle(fontSize: 16)),
|
||||
Expanded(child: _parseInlineMarkdown(line.substring(number.length + 2))),
|
||||
Expanded(
|
||||
child: _parseInlineMarkdown(
|
||||
line.substring(number.length + 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -323,7 +414,10 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widgets,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _parseInlineMarkdown(String text) {
|
||||
@@ -347,14 +441,22 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: matchedText.substring(2, matchedText.length - 2),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (matchedText.startsWith('*') && matchedText.endsWith('*')) {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: matchedText.substring(1, matchedText.length - 1),
|
||||
style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.black),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (matchedText.startsWith('[') && matchedText.contains('](')) {
|
||||
@@ -364,7 +466,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
spans.add(
|
||||
TextSpan(
|
||||
text: linkText,
|
||||
style: const TextStyle(fontSize: 16, color: Colors.blue, decoration: TextDecoration.underline),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -390,9 +496,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
return BlocProvider<BlogPostCreateCubit>(
|
||||
create: (BuildContext context) => blogPostCubit,
|
||||
child: BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
bloc: blogPostCubit,
|
||||
listener: (BuildContext context, BlogPostCreateState state) {
|
||||
if (state is BlogPostCreateStateLoaded) {
|
||||
if (state.isSuccess) {
|
||||
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||
ProfileCubit.myProfileCubit?.reloadBlogPosts();
|
||||
}
|
||||
},
|
||||
child: _buildBlogPostCreate(context),
|
||||
@@ -401,6 +509,10 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
}
|
||||
|
||||
void _showImagePicker() {
|
||||
if (_remainingSlots <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
@@ -415,14 +527,27 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
description: s.allowAccessToCameraDescription,
|
||||
onGranted: () async {
|
||||
final picker = ImagePicker();
|
||||
image = await picker.pickImage(source: ImageSource.camera);
|
||||
setState(() {});
|
||||
final image = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
if (image != null && _remainingSlots > 0) {
|
||||
setState(() {
|
||||
pendingImages.add(image);
|
||||
});
|
||||
blogPostCubit.clearImagesError();
|
||||
}
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
context: context,
|
||||
);
|
||||
},
|
||||
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.photo_camera),
|
||||
SizedBox(width: 8),
|
||||
Text('Фото'),
|
||||
],
|
||||
),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () async {
|
||||
@@ -432,30 +557,88 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
description: s.allowAcessToGaleryDescription,
|
||||
onGranted: () async {
|
||||
final picker = ImagePicker();
|
||||
image = await picker.pickImage(source: ImageSource.gallery);
|
||||
setState(() {});
|
||||
final images = await picker.pickMultiImage(
|
||||
limit: _remainingSlots,
|
||||
);
|
||||
if (images.isNotEmpty) {
|
||||
setState(() {
|
||||
pendingImages.addAll(images.take(_remainingSlots));
|
||||
});
|
||||
blogPostCubit.clearImagesError();
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
context: context,
|
||||
);
|
||||
},
|
||||
child: const Row(children: [Icon(Icons.photo), SizedBox(width: 8), Text('Галерея')]),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.photo),
|
||||
SizedBox(width: 8),
|
||||
Text('Галерея'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')),
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
isDestructiveAction: true,
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _removeSavedImage(int index) {
|
||||
final image = savedImages[index];
|
||||
final mediaId = image.id;
|
||||
setState(() {
|
||||
savedImages.removeAt(index);
|
||||
if (mediaId != null) {
|
||||
mediaIdsToDelete.add(mediaId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _removePendingImage(int index) {
|
||||
setState(() {
|
||||
pendingImages.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
void _onReorderGallery(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
|
||||
if (oldIndex < savedImages.length && newIndex < savedImages.length) {
|
||||
final item = savedImages.removeAt(oldIndex);
|
||||
savedImages.insert(newIndex, item);
|
||||
return;
|
||||
}
|
||||
|
||||
final pendingOld = oldIndex - savedImages.length;
|
||||
final pendingNew = newIndex - savedImages.length;
|
||||
if (pendingOld >= 0 && pendingNew >= 0) {
|
||||
final item = pendingImages.removeAt(pendingOld);
|
||||
pendingImages.insert(pendingNew, item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget _mdButton(IconData icon, Function() onTap) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F2F6),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon),
|
||||
),
|
||||
),
|
||||
@@ -468,11 +651,18 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F2F6),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.black87),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -496,7 +686,12 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
@@ -508,9 +703,15 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_italic, _applyItalic),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
||||
_mdButton(
|
||||
Icons.format_list_numbered,
|
||||
_applyNumberedList,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
||||
_mdButton(
|
||||
Icons.format_list_bulleted,
|
||||
_applyBulletedList,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.link, _applyLink),
|
||||
const SizedBox(width: 8),
|
||||
@@ -528,157 +729,297 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_mdButton(
|
||||
isPreviewMode ? Icons.edit : Icons.visibility,
|
||||
_togglePreview,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(children: [_mdButton(isPreviewMode ? Icons.edit : Icons.visibility, _togglePreview)]),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
appBar: AppBar(),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(key: AppKeys.blogPostCreateScreenTitleTextField, controller: titleController),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 8),
|
||||
isPreviewMode
|
||||
? _buildMarkdownPreview()
|
||||
: CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
controller: descriptionController,
|
||||
focusNode: descriptionFocusNode,
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (videoUrlController.text.isEmpty) const Text('Изображение поста'),
|
||||
if (videoUrlController.text.isEmpty) const SizedBox(height: 8),
|
||||
if (videoUrlController.text.isEmpty) _buildAddPhoto(),
|
||||
const SizedBox(height: 16),
|
||||
if (image == null && widget.feedBlogPostModel?.image == null) const Text('Ссылка на видео'),
|
||||
if (image == null && widget.feedBlogPostModel?.image == null) const SizedBox(height: 8),
|
||||
if (image == null && widget.feedBlogPostModel?.image == null)
|
||||
CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController),
|
||||
const SizedBox(height: 80),
|
||||
_createBlogPostButton(),
|
||||
const SizedBox(height: 80),
|
||||
Icon(Icons.photo_library, color: appTheme.blueGray300),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Добавить',
|
||||
style: TextStyle(fontSize: 12, color: appTheme.blueGray300),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _createBlogPostButton() {
|
||||
return InkWell(
|
||||
onTap: isLoading
|
||||
? null
|
||||
: () async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
if (widget.feedBlogPostModel != null) {
|
||||
final result = await blogPostCubit.updateBlogPost(
|
||||
id: widget.feedBlogPostModel!.id,
|
||||
title: titleController.text,
|
||||
description: descriptionController.text,
|
||||
videoUrl: videoUrlController.text,
|
||||
image: image,
|
||||
oldImageUrl: widget.feedBlogPostModel!.image,
|
||||
);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
if (!result) {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(context, title: S.current.could_not_update_blog_post);
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final result = await blogPostCubit.createBlogPost(
|
||||
title: titleController.text,
|
||||
description: descriptionController.text,
|
||||
videoUrl: videoUrlController.text,
|
||||
image: image,
|
||||
);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
if (!result) {
|
||||
if (!mounted) return;
|
||||
showErrorDialog(context, title: S.current.could_not_publish_blog_post);
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: isLoading
|
||||
? const Center(child: Loader())
|
||||
: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: appTheme.blueA400),
|
||||
child: const Center(
|
||||
child: Text('Создать', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
GestureDetector _buildAddPhoto() {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_showImagePicker();
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
if (image == null && widget.feedBlogPostModel?.image == null)
|
||||
AnimatedContainer(
|
||||
height: shrinkImage ? 52 : 128,
|
||||
curve: Curves.easeIn,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: AppDecoration.fillGray.copyWith(borderRadius: BorderRadius.circular(16)),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.photo_library, color: appTheme.blueGray300),
|
||||
const SizedBox(width: 8),
|
||||
Text("Добавить фото", selectionColor: appTheme.blueA400),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (image != null)
|
||||
SizedBox(
|
||||
width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32,
|
||||
Widget _buildGalleryTile({
|
||||
required int index,
|
||||
required double height,
|
||||
required ImageProvider imageProvider,
|
||||
required VoidCallback onRemove,
|
||||
}) {
|
||||
return ReorderableDelayedDragStartListener(
|
||||
index: index,
|
||||
child: Container(
|
||||
width: height,
|
||||
height: height,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image(image: FileImage(File(image!.path))),
|
||||
child: Image(image: imageProvider, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
if (widget.feedBlogPostModel?.image != null && image == null)
|
||||
SizedBox(
|
||||
width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image(image: NetworkImage('${sl<AppConfig>().baseUrl}/storage/${widget.feedBlogPostModel!.image}')),
|
||||
Positioned(
|
||||
top: -6,
|
||||
right: -6,
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
shape: const CircleBorder(),
|
||||
elevation: 1,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: onRemove,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: Icon(Icons.close, size: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,79 +1,122 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class BlogPostRepository {
|
||||
static const maxGalleryImages = 10;
|
||||
|
||||
final BlogPostService blogPostService;
|
||||
|
||||
BlogPostRepository({
|
||||
required this.blogPostService,
|
||||
});
|
||||
BlogPostRepository({required this.blogPostService});
|
||||
|
||||
Future<void> createBlogPost(
|
||||
Future<void> createBlogPost({
|
||||
String? title,
|
||||
String? videoUrl,
|
||||
String? description,
|
||||
XFile? image,
|
||||
) async {
|
||||
final formData = FormData.fromMap({
|
||||
if (title != null && title.isNotEmpty) 'title': title,
|
||||
if (description != null && description.isNotEmpty) 'description_md': description,
|
||||
if (videoUrl != null && videoUrl.isNotEmpty) 'video_url': videoUrl,
|
||||
if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name),
|
||||
});
|
||||
|
||||
return blogPostService.createBlogPost(
|
||||
formData: formData,
|
||||
List<XFile> images = const [],
|
||||
}) async {
|
||||
final formData = await _buildFormData(
|
||||
title: title,
|
||||
videoUrl: videoUrl,
|
||||
description: description,
|
||||
newImages: images,
|
||||
);
|
||||
|
||||
return blogPostService.createBlogPost(formData: formData);
|
||||
}
|
||||
|
||||
Future<XFile?> downloadImage(String imageUrl) async {
|
||||
final response = await http.get(Uri.parse('${sl<AppConfig>().baseUrl}/storage/$imageUrl'));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final directory = await getTemporaryDirectory();
|
||||
final filePath = '${directory.path}/image.jpg';
|
||||
final file = File(filePath);
|
||||
await file.writeAsBytes(response.bodyBytes);
|
||||
|
||||
return XFile(file.path);
|
||||
Future<void> updateBlogPost({
|
||||
required int id,
|
||||
String? title,
|
||||
String? videoUrl,
|
||||
String? description,
|
||||
List<int> existingMediaIds = const [],
|
||||
List<XFile> newImages = const [],
|
||||
List<int> mediaIdsToDelete = const [],
|
||||
}) async {
|
||||
if (mediaIdsToDelete.isNotEmpty) {
|
||||
await Future.wait(
|
||||
mediaIdsToDelete.map(
|
||||
(mediaId) =>
|
||||
blogPostService.deleteBlogPostMedia(postId: id, mediaId: mediaId),
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
final formData = await _buildFormData(
|
||||
title: title ?? '',
|
||||
videoUrl: videoUrl ?? '',
|
||||
description: description ?? '',
|
||||
existingMediaIds: existingMediaIds,
|
||||
newImages: newImages,
|
||||
includeExistingMediaIds: true,
|
||||
includeEmptyTextFields: true,
|
||||
);
|
||||
|
||||
return blogPostService.updateBlogPost(id: id, formData: formData);
|
||||
}
|
||||
|
||||
Future<void> updateBlogPost(
|
||||
int id,
|
||||
String? title,
|
||||
String? videoUrl,
|
||||
String? description,
|
||||
XFile? image,
|
||||
String? oldImageUrl,
|
||||
) async {
|
||||
final formData = FormData.fromMap({
|
||||
'title': title,
|
||||
'description_md': description,
|
||||
'video_url': videoUrl,
|
||||
'image': image != null
|
||||
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name)
|
||||
: oldImageUrl != '' && oldImageUrl != null
|
||||
? await MultipartFile.fromFile((await downloadImage(oldImageUrl))?.path ?? '', filename: oldImageUrl.split('/').last)
|
||||
: null,
|
||||
});
|
||||
|
||||
return blogPostService.updateBlogPost(
|
||||
id: id,
|
||||
formData: formData,
|
||||
);
|
||||
Future<void> deleteBlogPostMedia({
|
||||
required int postId,
|
||||
required int mediaId,
|
||||
}) async {
|
||||
await blogPostService.deleteBlogPostMedia(postId: postId, mediaId: mediaId);
|
||||
}
|
||||
|
||||
Future<void> deleteBlogPost(int id) async {
|
||||
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 {
|
||||
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
|
||||
|
||||
@POST('/api/blogPosts/create/published')
|
||||
@POST('/api/v2/blogPosts/create/published')
|
||||
@MultiPart()
|
||||
Future<void> createBlogPost({
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
Future<void> createBlogPost({@Body() required FormData formData});
|
||||
|
||||
@POST('/api/blogPosts/update/published/{id}')
|
||||
@PUT('/api/v2/blogPosts/update/published/{id}')
|
||||
@MultiPart()
|
||||
Future<void> updateBlogPost({
|
||||
@Path('id') required int id,
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
|
||||
@DELETE('/api/blogPosts/delete/{id}')
|
||||
Future<void> deleteBlogPost({
|
||||
@Path('id') required int id,
|
||||
@DELETE('/api/v2/blogPosts/{postId}/media/{mediaId}')
|
||||
Future<void> deleteBlogPostMedia({
|
||||
@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 bool isLocal;
|
||||
|
||||
/// When set, used instead of the image URL so source/destination tags stay unique.
|
||||
final Object? heroTag;
|
||||
|
||||
const PhotoViewScreen({
|
||||
super.key,
|
||||
required this.imageUrls,
|
||||
required this.index,
|
||||
this.isLocal = false,
|
||||
this.heroTag,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -36,6 +40,23 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
PhotoViewHeroAttributes? _heroAttributesFor(int index) {
|
||||
if (widget.heroTag != null) {
|
||||
if (index != widget.index) {
|
||||
return null;
|
||||
}
|
||||
return PhotoViewHeroAttributes(
|
||||
tag: widget.heroTag!,
|
||||
transitionOnUserGestures: true,
|
||||
);
|
||||
}
|
||||
|
||||
return PhotoViewHeroAttributes(
|
||||
tag: widget.imageUrls[index],
|
||||
transitionOnUserGestures: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DismissiblePage(
|
||||
@@ -44,23 +65,19 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
||||
direction: DismissiblePageDismissDirection.vertical,
|
||||
isFullScreen: true,
|
||||
child: PhotoViewGallery.builder(
|
||||
backgroundDecoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||
scrollPhysics: const BouncingScrollPhysics(),
|
||||
builder: (BuildContext context, int index) {
|
||||
final imageProvider = widget.isLocal
|
||||
? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
|
||||
: CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider<Object>;
|
||||
: CachedNetworkImageProvider(widget.imageUrls[index])
|
||||
as ImageProvider<Object>;
|
||||
|
||||
return PhotoViewGalleryPageOptions(
|
||||
imageProvider: imageProvider,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: widget.imageUrls[index],
|
||||
transitionOnUserGestures: true,
|
||||
),
|
||||
heroAttributes: _heroAttributesFor(index),
|
||||
errorBuilder: (context, object, stackTrace) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
@@ -76,8 +93,12 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
onTap: () async {
|
||||
if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) {
|
||||
throw Exception('Could not launch ${widget.imageUrls[index]}');
|
||||
if (!await launchUrl(
|
||||
Uri.parse(widget.imageUrls[index]),
|
||||
)) {
|
||||
throw Exception(
|
||||
'Could not launch ${widget.imageUrls[index]}',
|
||||
);
|
||||
}
|
||||
},
|
||||
leading: const Icon(
|
||||
@@ -89,7 +110,10 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
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(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
@@ -109,7 +133,9 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
||||
width: 20.0,
|
||||
height: 20.0,
|
||||
child: CircularProgressIndicator(
|
||||
value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
||||
value: event == null
|
||||
? 0
|
||||
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,6 +6,10 @@ import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'
|
||||
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
|
||||
@@ -69,6 +73,26 @@ abstract class ChatService {
|
||||
@Body() required Map<String, String> body,
|
||||
});
|
||||
|
||||
@GET('/api/chosen_plans/applied_promos')
|
||||
Future<PromoAppliedListResponse> listAppliedPromoCodes();
|
||||
|
||||
@POST('/api/chosen_plans/{id}/validate_promo')
|
||||
Future<PromoValidateResponse> validatePromoCode({
|
||||
@Path() required int id,
|
||||
@Body() required Map<String, String> body,
|
||||
});
|
||||
|
||||
@POST('/api/chosen_plans/{id}/apply_promo')
|
||||
Future<PromoApplyResponse> applyPromoCode({
|
||||
@Path() required int id,
|
||||
@Body() required Map<String, String> body,
|
||||
});
|
||||
|
||||
@DELETE('/api/chosen_plans/{id}/remove_promo_code')
|
||||
Future<PromoRemoveResponse> removePromoCode({
|
||||
@Path() required int id,
|
||||
});
|
||||
|
||||
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
|
||||
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
||||
@Path() required String orderId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'consultant_profile_model.freezed.dart';
|
||||
@@ -54,37 +55,7 @@ abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
|
||||
_$ConsultantProfileModelFromJson(json);
|
||||
|
||||
bool get hasValidPassportData {
|
||||
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);
|
||||
}
|
||||
bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty;
|
||||
}
|
||||
|
||||
@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/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/passport_data_validator.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
@@ -240,6 +241,9 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
bool? acceptOffer,
|
||||
bool? acceptFrdoAgreement,
|
||||
]) {
|
||||
final nextFieldErrors = state.fieldErrors.isEmpty
|
||||
? const <String, String>{}
|
||||
: PassportDataValidator.validate(consultant);
|
||||
emit(
|
||||
state.copyWith(
|
||||
consultant: consultant,
|
||||
@@ -247,11 +251,17 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||
loading: false,
|
||||
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}) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -322,8 +332,8 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
}
|
||||
|
||||
Future<void> registerConsultant() async {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(state.copyWith(error: 'Заполните паспортные данные'));
|
||||
final passportErrors = validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,6 +411,10 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
|
||||
return;
|
||||
}
|
||||
final passportErrors = validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: true,
|
||||
@@ -413,18 +427,6 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
);
|
||||
_update = true;
|
||||
try {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: false,
|
||||
isScoringPolling: false,
|
||||
awaitingScoringOutcome: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||
error: 'Заполните паспортные данные',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await consultantRepository.registerConsultant(
|
||||
consultant: buildSubmitPayload(),
|
||||
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_section_title.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||
@@ -332,6 +333,7 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
controller: innController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 12,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
hasError: errors.containsKey('inn'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -446,6 +448,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
controller: seriesController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 4,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
hasError: errors.containsKey('passport_series'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -460,6 +465,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
controller: numberController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
hasError: errors.containsKey('passport_number'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
|
||||
@@ -146,212 +146,230 @@ class _StepperBecomeConsultantScreenState
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<RegisterConsultantCubit>(
|
||||
create: (context) => registrationConsultantCubit,
|
||||
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
child: BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.awaitingScoringOutcome &&
|
||||
!current.awaitingScoringOutcome &&
|
||||
current.alfaScoringStatus.isComplete,
|
||||
previous.fieldErrors.isEmpty &&
|
||||
current.fieldErrors.isNotEmpty &&
|
||||
current.error == null,
|
||||
listener: (context, state) {
|
||||
_showSuccessSheet(context);
|
||||
pageController.jumpToPage(5);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.loading) {
|
||||
return const Scaffold(body: Loader());
|
||||
}
|
||||
if (state.error != null &&
|
||||
state.requestModel == null &&
|
||||
widget.slug != null &&
|
||||
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
GoRouter.of(context).pop();
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).go('/feed-screen');
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
'',
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
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,
|
||||
);
|
||||
});
|
||||
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.awaitingScoringOutcome &&
|
||||
!current.awaitingScoringOutcome &&
|
||||
current.alfaScoringStatus.isComplete,
|
||||
listener: (context, state) {
|
||||
_showSuccessSheet(context);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.loading) {
|
||||
return const Scaffold(body: Loader());
|
||||
}
|
||||
if (state.error != null &&
|
||||
state.requestModel == null &&
|
||||
widget.slug != null &&
|
||||
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
GoRouter.of(context).pop();
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).go('/feed-screen');
|
||||
},
|
||||
label: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
title: Text(
|
||||
'',
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
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'),
|
||||
],
|
||||
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,
|
||||
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),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0x33FFCC00),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
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(
|
||||
'Комментарий',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1,
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true && step != totalSteps
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: submitDisabled
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: submitDisabled
|
||||
? null
|
||||
: () async {
|
||||
if (step == totalSteps) {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: const Text(
|
||||
'Заполните паспортные данные',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit
|
||||
.hasAcceptedRequiredAgreements) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit
|
||||
.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit.state.error !=
|
||||
null) {
|
||||
showDialog(
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true && step != totalSteps
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: submitDisabled
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: submitDisabled
|
||||
? null
|
||||
: () async {
|
||||
if (step == totalSteps) {
|
||||
final passportErrors =
|
||||
registrationConsultantCubit
|
||||
.validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
pageController.jumpToPage(5);
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit
|
||||
.hasAcceptedRequiredAgreements) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit
|
||||
.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit
|
||||
.state
|
||||
.error !=
|
||||
null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
registrationConsultantCubit
|
||||
.state
|
||||
.error!,
|
||||
),
|
||||
actions: [
|
||||
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,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
registrationConsultantCubit
|
||||
.state
|
||||
.error!,
|
||||
),
|
||||
content: Text(e.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
@@ -361,140 +379,120 @@ class _StepperBecomeConsultantScreenState
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
// Success sheet is shown by BlocListener when scoring completes.
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(e.toString()),
|
||||
content: Text(
|
||||
state.isUpdating
|
||||
? 'Примите договор публичной оферты'
|
||||
: 'Примите оферту и согласие ФРДО',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
state.isUpdating
|
||||
? 'Примите договор публичной оферты'
|
||||
: 'Примите оферту и согласие ФРДО',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
label: submitDisabled && step == totalSteps
|
||||
? const CircularProgressIndicator()
|
||||
: 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,
|
||||
},
|
||||
label: submitDisabled && step == totalSteps
|
||||
? const CircularProgressIndicator()
|
||||
: 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(
|
||||
ImageConstant.imgLogoEatvio,
|
||||
height: 48,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(
|
||||
ImageConstant.imgLogoEatvio,
|
||||
height: 48,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Text('шаг $step из $totalSteps'),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(
|
||||
begin: 1 / totalSteps,
|
||||
end: progressValue,
|
||||
),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
appTheme.blueA400,
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Text('шаг $step из $totalSteps'),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(
|
||||
begin: 1 / totalSteps,
|
||||
end: progressValue,
|
||||
),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / totalSteps;
|
||||
});
|
||||
},
|
||||
children: _pages(),
|
||||
),
|
||||
);
|
||||
},
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / totalSteps;
|
||||
});
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'feed_blog_post_model.freezed.dart';
|
||||
@@ -20,6 +21,9 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel {
|
||||
@JsonKey(name: "total_views") required int totalViews,
|
||||
@JsonKey(name: "total_comments") required int totalComments,
|
||||
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
|
||||
@Default([]) List<FeedGalleryImage> images,
|
||||
@JsonKey(name: "video_url_embed") String? videoEmbedUrl,
|
||||
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||
}) = _FeedBlogPostModel;
|
||||
|
||||
factory FeedBlogPostModel.fromJson(Map<String, Object?> json) => _$FeedBlogPostModelFromJson(json);
|
||||
|
||||
41
lib/features/feed/models/post_feed_mapper.dart
Normal file
41
lib/features/feed/models/post_feed_mapper.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
|
||||
class PostFeedMapper {
|
||||
static FeedBlogPostModel fromPostFeedItem(PostFeedItemModel item, {int? currentUserId}) {
|
||||
return FeedBlogPostModel(
|
||||
id: item.postId,
|
||||
state: item.postState,
|
||||
image: resolveMediaUrl(item.postImageUrl),
|
||||
description: item.postTextMd ?? item.postShortDescription ?? '',
|
||||
author: Author(
|
||||
authorSlug: item.postAuthorDetails?.slug ?? '',
|
||||
authorName: item.postAuthorName,
|
||||
authorProfileIcon: resolveMediaUrl(item.postAuthorImageUrl),
|
||||
authorIsMe: currentUserId != null && item.postAuthorId == currentUserId,
|
||||
isEntitySubscribedByMe: item.isEntitySubscribedByMe,
|
||||
),
|
||||
videoUrl: '',
|
||||
createdAt: item.postCreatedAt,
|
||||
updatedAt: item.updatedAt,
|
||||
title: item.postTitle,
|
||||
totalLikes: item.postLikesCount,
|
||||
totalViews: item.postViewsCount,
|
||||
totalComments: item.postCommentsCount,
|
||||
wasLikedByMe: item.postIsLikedByUser,
|
||||
images: item.images,
|
||||
videoEmbedUrl: item.videoEmbedUrl,
|
||||
videoUrlThumb: item.videoUrlThumb,
|
||||
);
|
||||
}
|
||||
|
||||
static String resolveMediaUrl(String? url) {
|
||||
if (url == null || url.isEmpty) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
|
||||
return '${sl<AppConfig>().baseUrl}/storage/$url';
|
||||
}
|
||||
}
|
||||
95
lib/features/feed/models/post_feed_pagination_model.dart
Normal file
95
lib/features/feed/models/post_feed_pagination_model.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'post_feed_pagination_model.freezed.dart';
|
||||
part 'post_feed_pagination_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PostFeedPaginationModel with _$PostFeedPaginationModel {
|
||||
const factory PostFeedPaginationModel({
|
||||
@JsonKey(name: 'data') required List<PostFeedItemModel> data,
|
||||
required CursorLinks links,
|
||||
required CursorMeta meta,
|
||||
}) = _PostFeedPaginationModel;
|
||||
|
||||
factory PostFeedPaginationModel.fromJson(Map<String, Object?> json) => _$PostFeedPaginationModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class PostFeedItemModel with _$PostFeedItemModel {
|
||||
const factory PostFeedItemModel({
|
||||
required int id,
|
||||
@JsonKey(name: 'post_id') required int postId,
|
||||
@JsonKey(name: 'post_author_id') required int postAuthorId,
|
||||
@JsonKey(name: 'post_author_name') required String postAuthorName,
|
||||
@JsonKey(name: 'post_author_image_url') String? postAuthorImageUrl,
|
||||
@JsonKey(name: 'post_author_profile_url') String? postAuthorProfileUrl,
|
||||
@JsonKey(name: 'post_created_at', fromJson: parseDateTime) required DateTime postCreatedAt,
|
||||
@JsonKey(name: 'post_title') required String postTitle,
|
||||
@JsonKey(name: 'post_short_description') String? postShortDescription,
|
||||
@JsonKey(name: 'post_text_md') String? postTextMd,
|
||||
@JsonKey(name: 'post_image_url') String? postImageUrl,
|
||||
@Default([]) List<FeedGalleryImage> images,
|
||||
@JsonKey(name: 'video_embed_url') String? videoEmbedUrl,
|
||||
@JsonKey(name: 'post_views_count') required int postViewsCount,
|
||||
@JsonKey(name: 'post_likes_count') required int postLikesCount,
|
||||
@JsonKey(name: 'post_comments_count') required int postCommentsCount,
|
||||
@JsonKey(name: 'post_is_liked_by_user') required bool postIsLikedByUser,
|
||||
@JsonKey(name: 'is_entity_subscribed_by_me') bool? isEntitySubscribedByMe,
|
||||
@JsonKey(name: 'post_url') String? postUrl,
|
||||
@JsonKey(name: 'post_state') required String postState,
|
||||
@JsonKey(name: 'post_author_detailz') PostAuthorDetails? postAuthorDetails,
|
||||
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||
@JsonKey(name: 'created_at', fromJson: parseDateTime) required DateTime createdAt,
|
||||
@JsonKey(name: 'updated_at', fromJson: parseDateTime) required DateTime updatedAt,
|
||||
}) = _PostFeedItemModel;
|
||||
|
||||
factory PostFeedItemModel.fromJson(Map<String, Object?> json) => _$PostFeedItemModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class FeedGalleryImage with _$FeedGalleryImage {
|
||||
const factory FeedGalleryImage({
|
||||
required String url,
|
||||
@Default('horizontal') String orientation,
|
||||
int? id,
|
||||
int? position,
|
||||
}) = _FeedGalleryImage;
|
||||
|
||||
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class PostAuthorDetails with _$PostAuthorDetails {
|
||||
const factory PostAuthorDetails({
|
||||
required String slug,
|
||||
@JsonKey(name: 'is_consultant') required bool isConsultant,
|
||||
}) = _PostAuthorDetails;
|
||||
|
||||
factory PostAuthorDetails.fromJson(Map<String, Object?> json) => _$PostAuthorDetailsFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CursorLinks with _$CursorLinks {
|
||||
const factory CursorLinks({
|
||||
required String? first,
|
||||
required String? last,
|
||||
required String? prev,
|
||||
required String? next,
|
||||
}) = _CursorLinks;
|
||||
|
||||
factory CursorLinks.fromJson(Map<String, Object?> json) => _$CursorLinksFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CursorMeta with _$CursorMeta {
|
||||
const factory CursorMeta({
|
||||
required String path,
|
||||
@JsonKey(name: 'per_page') required int perPage,
|
||||
@JsonKey(name: 'next_cursor') String? nextCursor,
|
||||
@JsonKey(name: 'prev_cursor') String? prevCursor,
|
||||
}) = _CursorMeta;
|
||||
|
||||
factory CursorMeta.fromJson(Map<String, Object?> json) => _$CursorMetaFromJson(json);
|
||||
}
|
||||
|
||||
DateTime parseDateTime(String date) => DateTime.parse(date).toLocal();
|
||||
@@ -8,8 +8,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class FeedCubit extends Cubit<FeedState> {
|
||||
final FeedRepository feedRepository;
|
||||
int currentPage = 1;
|
||||
late int lastPage;
|
||||
String? _nextCursor;
|
||||
bool _hasMore = true;
|
||||
bool isLoading = false;
|
||||
String? _query;
|
||||
|
||||
@@ -38,13 +38,16 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadFeedBlogPostList({String? query}) async {
|
||||
Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
|
||||
try {
|
||||
_query = query;
|
||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(query: query);
|
||||
lastPage = feedBlogPosts.meta.lastPage;
|
||||
_nextCursor = null;
|
||||
_hasMore = true;
|
||||
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
if (!isClosed) {
|
||||
emit(FeedState.loaded(feedBlogPostList: feedBlogPosts.feedBlogPostList, isLoading: false));
|
||||
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -54,29 +57,35 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadNextPage() async {
|
||||
if (currentPage < lastPage && !isLoading && !isClosed) {
|
||||
isLoading = true;
|
||||
Future<void> loadNextPage({int? currentUserId}) async {
|
||||
if (!_hasMore || isLoading || isClosed || _nextCursor == null || state is! FeedStateLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isClosed) {
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: isLoading));
|
||||
}
|
||||
isLoading = true;
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
if (currentPage < lastPage && !isClosed) {
|
||||
currentPage++;
|
||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(page: currentPage, query: _query);
|
||||
isLoading = false;
|
||||
if (!isClosed) {
|
||||
emit(FeedState.loaded(feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feedBlogPosts.feedBlogPostList], isLoading: isLoading));
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
if (!isClosed) {
|
||||
emit(FeedState.error(errorMessage: e.toString()));
|
||||
}
|
||||
try {
|
||||
final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
if (!isClosed && state is FeedStateLoaded) {
|
||||
emit(
|
||||
FeedState.loaded(
|
||||
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
if (!isClosed && state is FeedStateLoaded) {
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: false));
|
||||
} else if (!isClosed) {
|
||||
emit(FeedState.error(errorMessage: e.toString()));
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
final state = feedCubit.state;
|
||||
// Загружаем данные, если они еще не загружены
|
||||
if (state is! FeedStateLoaded) {
|
||||
feedCubit.loadFeedBlogPostList();
|
||||
feedCubit.loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id);
|
||||
}
|
||||
});
|
||||
super.initState();
|
||||
@@ -201,7 +201,10 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(query: query);
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||
query: query,
|
||||
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||
);
|
||||
break;
|
||||
case 1 when !hidePlans:
|
||||
context.read<PlansCubit>().loadPlans(query: query);
|
||||
@@ -224,7 +227,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||
);
|
||||
break;
|
||||
case 1 when !hidePlans:
|
||||
context.read<PlansCubit>().loadPlans();
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:carousel_slider_plus/carousel_slider_plus.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/widgets/feed_video_player/feed_video_player_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Gallery + optional embed video for feed card and blog post detail.
|
||||
class BlogPostMediaContent extends StatefulWidget {
|
||||
const BlogPostMediaContent({
|
||||
super.key,
|
||||
required this.galleryImages,
|
||||
required this.imageUrl,
|
||||
required this.videoEmbedUrl,
|
||||
required this.currentGalleryIndex,
|
||||
required this.onOpenGallery,
|
||||
required this.onGalleryChanged,
|
||||
required this.videoUrlThumb,
|
||||
this.heroTag,
|
||||
});
|
||||
|
||||
final List<FeedGalleryImage> galleryImages;
|
||||
final String imageUrl;
|
||||
final String? videoEmbedUrl;
|
||||
final String? videoUrlThumb;
|
||||
final int currentGalleryIndex;
|
||||
final void Function(List<String> urls, int index) onOpenGallery;
|
||||
final ValueChanged<int> onGalleryChanged;
|
||||
|
||||
/// Unique per post. Required when several cards with the same [imageUrl] share a route.
|
||||
final Object? heroTag;
|
||||
|
||||
@override
|
||||
State<BlogPostMediaContent> createState() => _BlogPostMediaContentState();
|
||||
}
|
||||
|
||||
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
static const _counterDisplayDuration = Duration(seconds: 1);
|
||||
|
||||
/// Landscape / video slide aspect (width / height).
|
||||
static const _horizontalAspectRatio = 16 / 9;
|
||||
|
||||
/// Portrait slide aspect (width / height).
|
||||
static const _verticalAspectRatio = 3 / 4;
|
||||
|
||||
bool _isCounterVisible = false;
|
||||
Timer? _counterHideTimer;
|
||||
|
||||
bool get _hasVideo =>
|
||||
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
||||
|
||||
List<FeedGalleryImage> get _gallery {
|
||||
return [
|
||||
for (final image in widget.galleryImages)
|
||||
if (PostFeedMapper.resolveMediaUrl(image.url) case final url
|
||||
when url.isNotEmpty)
|
||||
FeedGalleryImage(
|
||||
url: url,
|
||||
orientation: image.orientation,
|
||||
id: image.id,
|
||||
position: image.position,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<String> get _galleryUrls => _gallery.map((e) => e.url).toList();
|
||||
|
||||
int get _slideCount {
|
||||
if (_gallery.isNotEmpty) {
|
||||
return _gallery.length + (_hasVideo ? 1 : 0);
|
||||
}
|
||||
if (_hasVideo) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool get _isCurrentSlideVideo =>
|
||||
_hasVideo && widget.currentGalleryIndex == _gallery.length;
|
||||
|
||||
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
||||
|
||||
/// Height is fixed for the whole post from the first gallery image (web-like).
|
||||
String get _postOrientation {
|
||||
if (_gallery.isNotEmpty) {
|
||||
return _gallery.first.orientation;
|
||||
}
|
||||
return 'horizontal';
|
||||
}
|
||||
|
||||
double _heightForOrientation(String orientation, double width) {
|
||||
final aspectRatio = orientation == 'vertical'
|
||||
? _verticalAspectRatio
|
||||
: _horizontalAspectRatio;
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
|
||||
return (width / aspectRatio).clamp(180.0, maxHeight);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_counterHideTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
||||
_scheduleCounterHideOnVideo();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleCounterHideOnVideo() {
|
||||
_counterHideTimer?.cancel();
|
||||
if (!_isCurrentSlideVideo) return;
|
||||
|
||||
_counterHideTimer = Timer(_counterDisplayDuration, () {
|
||||
if (mounted && _isCurrentSlideVideo) {
|
||||
setState(() => _isCounterVisible = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool _handleCarouselScroll(ScrollNotification notification) {
|
||||
if (notification is ScrollStartNotification ||
|
||||
notification is ScrollUpdateNotification) {
|
||||
_counterHideTimer?.cancel();
|
||||
if (!_isCounterVisible) {
|
||||
setState(() => _isCounterVisible = true);
|
||||
}
|
||||
} else if (notification is ScrollEndNotification &&
|
||||
_isCounterVisible &&
|
||||
_isCurrentSlideVideo) {
|
||||
_scheduleCounterHideOnVideo();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Widget _imageSlide(String itemUrl, int itemIndex, double height) {
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
height: height,
|
||||
imageUrl: itemUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _videoSlide() {
|
||||
return FeedVideoPlayer(
|
||||
embedUrl: widget.videoEmbedUrl!,
|
||||
videoUrlThumb: widget.videoUrlThumb,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _carousel({
|
||||
required int itemCount,
|
||||
required NullableIndexedWidgetBuilder itemBuilder,
|
||||
required double height,
|
||||
}) {
|
||||
return Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleCarouselScroll,
|
||||
child: CarouselSlider.builder(
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, itemIndex, _) =>
|
||||
itemBuilder(context, itemIndex) ?? const SizedBox.shrink(),
|
||||
options: CarouselOptions(
|
||||
viewportFraction: 1,
|
||||
height: height,
|
||||
enableInfiniteScroll: false,
|
||||
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (itemCount > 1)
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: _shouldShowCounter ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: _GalleryPhotoCounter(
|
||||
currentIndex: widget.currentGalleryIndex,
|
||||
total: itemCount,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gallery = _gallery;
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final mediaHeight = _heightForOrientation(_postOrientation, width);
|
||||
|
||||
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
|
||||
final itemCount = _slideCount;
|
||||
return _carousel(
|
||||
itemCount: itemCount,
|
||||
height: mediaHeight,
|
||||
itemBuilder: (context, itemIndex) {
|
||||
if (itemIndex < gallery.length) {
|
||||
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
|
||||
}
|
||||
if (_hasVideo) {
|
||||
return _videoSlide();
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (gallery.length == 1) {
|
||||
return _imageSlide(gallery.first.url, 0, mediaHeight);
|
||||
}
|
||||
|
||||
if (_hasVideo) {
|
||||
return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
|
||||
}
|
||||
|
||||
if (widget.imageUrl.isNotEmpty) {
|
||||
final image = CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: width,
|
||||
imageUrl: widget.imageUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
);
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||
child: widget.heroTag == null
|
||||
? image
|
||||
: Hero(tag: widget.heroTag!, child: image),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class _GalleryPhotoCounter extends StatelessWidget {
|
||||
const _GalleryPhotoCounter({required this.currentIndex, required this.total});
|
||||
|
||||
final int currentIndex;
|
||||
final int total;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${currentIndex + 1} / $total',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
@@ -9,8 +8,10 @@ import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.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/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/widgets/blog_post_media_content.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -19,9 +20,9 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class BlogPostWidget extends StatefulWidget {
|
||||
final int index;
|
||||
final FeedBlogPostModel post;
|
||||
|
||||
const BlogPostWidget({super.key, required this.index});
|
||||
const BlogPostWidget({super.key, required this.post});
|
||||
|
||||
@override
|
||||
State<BlogPostWidget> createState() => _BlogPostWidgetState();
|
||||
@@ -30,243 +31,343 @@ class BlogPostWidget extends StatefulWidget {
|
||||
class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
late bool isLiked;
|
||||
late int totalLikes;
|
||||
bool isMoreOptionsVisible = false;
|
||||
int currentGalleryIndex = 0;
|
||||
|
||||
FeedBlogPostModel get post => widget.post;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
isLiked = post.wasLikedByMe;
|
||||
totalLikes = post.totalLikes;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BlogPostWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.post.id != post.id) {
|
||||
currentGalleryIndex = 0;
|
||||
isLiked = post.wasLikedByMe;
|
||||
totalLikes = post.totalLikes;
|
||||
return;
|
||||
}
|
||||
if (oldWidget.post.wasLikedByMe != post.wasLikedByMe ||
|
||||
oldWidget.post.totalLikes != post.totalLikes) {
|
||||
isLiked = post.wasLikedByMe;
|
||||
totalLikes = post.totalLikes;
|
||||
}
|
||||
}
|
||||
|
||||
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleLike() async {
|
||||
final nextLiked = !isLiked;
|
||||
setState(() {
|
||||
isLiked = nextLiked;
|
||||
nextLiked ? totalLikes++ : totalLikes--;
|
||||
});
|
||||
context.read<FeedCubit>().updateFeedBlogPostList(post, nextLiked);
|
||||
if (nextLiked) {
|
||||
await context.read<FeedCubit>().likePost(post.id);
|
||||
} else {
|
||||
await context.read<FeedCubit>().unlikedPost(post.id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<FeedCubit, FeedState>(
|
||||
builder: (BuildContext context, FeedState state) {
|
||||
if (state is FeedStateLoaded) {
|
||||
isLiked = state.feedBlogPostList[widget.index].wasLikedByMe;
|
||||
totalLikes = state.feedBlogPostList[widget.index].totalLikes;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: state.feedBlogPostList[widget.index].author.authorSlug);
|
||||
},
|
||||
child: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: NetworkImage(
|
||||
state.feedBlogPostList[widget.index].author.authorProfileIcon.contains('https')
|
||||
? state.feedBlogPostList[widget.index].author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl + state.feedBlogPostList[widget.index].author.authorProfileIcon,
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Имя и дата внутри Expanded
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.feedBlogPostList[widget.index].author.authorName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(state.feedBlogPostList[widget.index].createdAt)} в ${state.feedBlogPostList[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.feedBlogPostList[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Кнопка подписки (если нужно)
|
||||
if (!(state.feedBlogPostList[widget.index].author.isEntitySubscribedByMe ?? false) &&
|
||||
!state.feedBlogPostList[widget.index].author.authorIsMe)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: SubscribeButtonWidget(slug: state.feedBlogPostList[widget.index].author.authorSlug),
|
||||
),
|
||||
// Кнопка меню
|
||||
CustomPopup(
|
||||
isAuthor: state.feedBlogPostList[widget.index].author.authorIsMe,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${(state.feedBlogPostList[widget.index].id)}',
|
||||
id: state.feedBlogPostList[widget.index].id,
|
||||
feedBlogPostModel: state.feedBlogPostList[widget.index],
|
||||
userSlug: state.feedBlogPostList[widget.index].author.authorSlug,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
state.feedBlogPostList[widget.index].title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.feedBlogPostList[widget.index].title.isEmpty) const SizedBox(height: 8),
|
||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty && state.feedBlogPostList[widget.index].description.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
if (state.feedBlogPostList[widget.index].description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
String description = state.feedBlogPostList[widget.index].description;
|
||||
final isLong = description.length > 200;
|
||||
|
||||
if (isLong) {
|
||||
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
|
||||
int cutPoint = 200;
|
||||
|
||||
// Проверяем, есть ли незакрытые markdown теги
|
||||
String preview = description.substring(0, cutPoint);
|
||||
|
||||
// Считаем открывающие и закрывающие теги
|
||||
int boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||
bool hasUnclosedBold = boldOpen > boldClose;
|
||||
|
||||
// Если есть незакрытый bold, ищем закрытие
|
||||
if (hasUnclosedBold) {
|
||||
int searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||
int boldClosePos = description.indexOf('**', cutPoint);
|
||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||
cutPoint = boldClosePos + 2;
|
||||
} else {
|
||||
// Если не нашли, обрезаем до последнего пробела
|
||||
cutPoint = description.lastIndexOf(' ', 200);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
} else {
|
||||
// Обрезаем до последнего пробела для красоты
|
||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
|
||||
description = description.substring(0, cutPoint);
|
||||
}
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: description,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state.feedBlogPostList[widget.index].description.length > 200)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'Показать ещё',
|
||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty || state.feedBlogPostList[widget.index].description.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}'], index: 0),
|
||||
);
|
||||
},
|
||||
child: Hero(
|
||||
tag: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
isLiked = !isLiked;
|
||||
isLiked ? totalLikes++ : totalLikes--;
|
||||
});
|
||||
// Update feed list immediately by ID (no reload)
|
||||
context.read<FeedCubit>().updateFeedBlogPostList(state.feedBlogPostList[widget.index], isLiked);
|
||||
// Fire network call without introducing UI delay
|
||||
if (isLiked) {
|
||||
await context.read<FeedCubit>().likePost(state.feedBlogPostList[widget.index].id);
|
||||
} else {
|
||||
await context.read<FeedCubit>().unlikedPost(state.feedBlogPostList[widget.index].id);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
'assets/icons/comment.svg',
|
||||
height: 20,
|
||||
width: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
),
|
||||
text: state.feedBlogPostList[widget.index].totalComments.toString(),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
height: 15,
|
||||
width: 12,
|
||||
'assets/icons/repost.svg',
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
),
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.feedBlogPostList[widget.index].id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
state.feedBlogPostList[widget.index].totalViews.toString(),
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () =>
|
||||
GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_BlogPostHeader(post: post),
|
||||
_BlogPostTextContent(post: post),
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'feed-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'feed-blog-post-${post.id}-cover'
|
||||
: null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
const SizedBox(height: 12),
|
||||
_BlogPostActionsRow(
|
||||
post: post,
|
||||
isLiked: isLiked,
|
||||
totalLikes: totalLikes,
|
||||
onToggleLike: _toggleLike,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlogPostHeader extends StatelessWidget {
|
||||
const _BlogPostHeader({required this.post});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => GoRouter.of(
|
||||
context,
|
||||
).pushNamed('profileScreen', extra: post.author.authorSlug),
|
||||
child: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: post.author.authorProfileIcon.isNotEmpty
|
||||
? NetworkImage(
|
||||
PostFeedMapper.resolveMediaUrl(
|
||||
post.author.authorProfileIcon,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.author.authorName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black45,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!(post.author.isEntitySubscribedByMe ?? false) &&
|
||||
!post.author.authorIsMe)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: SubscribeButtonWidget(slug: post.author.authorSlug),
|
||||
),
|
||||
CustomPopup(
|
||||
isAuthor: post.author.authorIsMe,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${post.id}',
|
||||
id: post.id,
|
||||
feedBlogPostModel: post,
|
||||
userSlug: post.author.authorSlug,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlogPostTextContent extends StatelessWidget {
|
||||
const _BlogPostTextContent({required this.post});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (post.title.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
post.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (post.title.isEmpty) const SizedBox(height: 8),
|
||||
if (post.title.isNotEmpty && post.description.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
if (post.description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText: _truncateDescription(post.description),
|
||||
),
|
||||
),
|
||||
if (post.description.length > 200)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'Показать ещё',
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: Color(0xFF8B8B8B),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlogPostActionsRow extends StatelessWidget {
|
||||
const _BlogPostActionsRow({
|
||||
required this.post,
|
||||
required this.isLiked,
|
||||
required this.totalLikes,
|
||||
required this.onToggleLike,
|
||||
});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
final bool isLiked;
|
||||
final int totalLikes;
|
||||
final Future<void> Function() onToggleLike;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: isLiked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
onTap: onToggleLike,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
'assets/icons/comment.svg',
|
||||
height: 20,
|
||||
width: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
),
|
||||
text: post.totalComments.toString(),
|
||||
onTap: () => GoRouter.of(
|
||||
context,
|
||||
).pushNamed('blogPostScreen', extra: post.id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
height: 15,
|
||||
width: 12,
|
||||
'assets/icons/repost.svg',
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
),
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse(
|
||||
'${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}',
|
||||
),
|
||||
sharePositionOrigin:
|
||||
box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
post.totalViews.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appTheme.gray500,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _truncateDescription(String description) {
|
||||
final isLong = description.length > 200;
|
||||
if (!isLong) return description;
|
||||
|
||||
var cutPoint = 200;
|
||||
final preview = description.substring(0, cutPoint);
|
||||
final boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||
final hasUnclosedBold = boldOpen > boldClose;
|
||||
|
||||
if (hasUnclosedBold) {
|
||||
final searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||
final boldClosePos = description.indexOf('**', cutPoint);
|
||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||
cutPoint = boldClosePos + 2;
|
||||
} else {
|
||||
cutPoint = description.lastIndexOf(' ', 200);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
} else {
|
||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
|
||||
return description.substring(0, cutPoint);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.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/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -11,18 +12,18 @@ import 'package:visibility_detector/visibility_detector.dart';
|
||||
|
||||
class BlogPosts extends StatelessWidget {
|
||||
const BlogPosts({super.key, required this.handleVisibility});
|
||||
final Function({required double visibleFraction,required int id, required ViewableContentType type}) handleVisibility;
|
||||
|
||||
final Function({required double visibleFraction, required int id, required ViewableContentType type}) handleVisibility;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(),
|
||||
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
|
||||
child: BlocBuilder<FeedCubit, FeedState>(
|
||||
builder: (BuildContext context, FeedState state) {
|
||||
if (state is FeedStateLoaded) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
loadNewContent(notification, context.read<FeedCubit>().loadNextPage);
|
||||
loadNewContent(notification, () => context.read<FeedCubit>().loadNextPage(currentUserId: context.read<FeedProfileCubit>().state.profile?.id));
|
||||
return false;
|
||||
},
|
||||
child: ListView.separated(
|
||||
@@ -32,13 +33,13 @@ class BlogPosts extends StatelessWidget {
|
||||
if (index == state.feedBlogPostList.length) {
|
||||
return const Loader();
|
||||
}
|
||||
final post = state.feedBlogPostList[index];
|
||||
return VisibilityDetector(
|
||||
key: ValueKey(post.id),
|
||||
onVisibilityChanged: (info) {
|
||||
final id = state.feedBlogPostList[index].id;
|
||||
handleVisibility(visibleFraction: info.visibleFraction, id: id, type: ViewableContentType.posts);
|
||||
handleVisibility(visibleFraction: info.visibleFraction, id: post.id, type: ViewableContentType.posts);
|
||||
},
|
||||
key: ValueKey(index),
|
||||
child: BlogPostWidget(index: index),
|
||||
child: BlogPostWidget(post: post),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
@@ -46,7 +47,9 @@ class BlogPosts extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
} else if (state is FeedStateError) {
|
||||
return DataErrorWidget(onReload: () => context.read<FeedCubit>().loadFeedBlogPostList());
|
||||
return DataErrorWidget(
|
||||
onReload: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
|
||||
);
|
||||
} else {
|
||||
return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
|
||||
|
||||
enum FeedVideoEmbedProvider { vk, other }
|
||||
|
||||
/// Сборка URL и HTML для embed-плеера ленты.
|
||||
abstract final class FeedVideoEmbed {
|
||||
static FeedVideoEmbedProvider detectProvider(Uri uri) {
|
||||
final host = uri.host.toLowerCase();
|
||||
final isVk = host == 'vk.com' ||
|
||||
host.endsWith('.vk.com') ||
|
||||
host == 'vk.ru' ||
|
||||
host.endsWith('.vk.ru') ||
|
||||
host == 'vkvideo.ru' ||
|
||||
host.endsWith('.vkvideo.ru');
|
||||
return isVk ? FeedVideoEmbedProvider.vk : FeedVideoEmbedProvider.other;
|
||||
}
|
||||
|
||||
/// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки.
|
||||
static Uri uriForPlayback(String url) {
|
||||
final uri = Uri.parse(url);
|
||||
final params = Map<String, String>.from(uri.queryParameters);
|
||||
params['autoplay'] = '1';
|
||||
params['mute'] = '1';
|
||||
params.putIfAbsent('playsinline', () => '1');
|
||||
|
||||
// VK Video JS API: без js_api=1 события/методы плеера недоступны.
|
||||
if (detectProvider(uri) == FeedVideoEmbedProvider.vk) {
|
||||
params['js_api'] = '1';
|
||||
}
|
||||
|
||||
return uri.replace(queryParameters: params);
|
||||
}
|
||||
|
||||
static String buildHtml(String embedUrl) {
|
||||
final uri = Uri.parse(embedUrl);
|
||||
final isVk = detectProvider(uri) == FeedVideoEmbedProvider.vk;
|
||||
final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl);
|
||||
final headScripts = isVk ? '<script src="${FeedVideoEmbedScripts.vkVideoPlayerApiSrc}"></script>' : '';
|
||||
final bootstrap = FeedVideoEmbedScripts.bootstrap(includeVk: isVk);
|
||||
|
||||
return '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
|
||||
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
||||
</style>
|
||||
$headScripts
|
||||
</head>
|
||||
<body>
|
||||
<iframe
|
||||
id="player"
|
||||
src="$src"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen;"
|
||||
allowfullscreen
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
></iframe>
|
||||
<script>
|
||||
$bootstrap
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/// JS-фрагменты для embed-плеера ленты.
|
||||
///
|
||||
/// Канал связи с Flutter: [readyChannel] через `flutter_inappwebview.callHandler`.
|
||||
abstract final class FeedVideoEmbedScripts {
|
||||
static const readyChannel = 'EmbedReady';
|
||||
|
||||
/// Уведомляет Flutter, что embed готов к показу.
|
||||
static String get notifyReady =>
|
||||
'''
|
||||
function notifyReady() {
|
||||
try {
|
||||
window.flutter_inappwebview.callHandler('$readyChannel', 'ready');
|
||||
} catch (e) {}
|
||||
}
|
||||
''';
|
||||
|
||||
/// Уведомляет Flutter об ошибке воспроизведения.
|
||||
static String get notifyError =>
|
||||
'''
|
||||
function notifyError() {
|
||||
try {
|
||||
window.flutter_inappwebview.callHandler('$readyChannel', 'error');
|
||||
} catch (e) {}
|
||||
}
|
||||
''';
|
||||
|
||||
/// Rutube: готовность через postMessage `player:canPlay`.
|
||||
static String get rutubeCanPlayListener => '''
|
||||
window.addEventListener('message', function (event) {
|
||||
try {
|
||||
var type = JSON.parse(event.data).type;
|
||||
if (type === 'player:canPlay') {
|
||||
notifyReady();
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
''';
|
||||
|
||||
/// VK Video JS API: воспроизведение началось на событии `started`.
|
||||
/// Требует `js_api=1` в URL iframe и скрипт `videoplayer.js`.
|
||||
static String get vkPlayerReadyHandler => '''
|
||||
try {
|
||||
if (typeof VK !== 'undefined' && VK.VideoPlayer) {
|
||||
var vkPlayer = VK.VideoPlayer(document.getElementById('player'));
|
||||
vkPlayer.on('started', function () {
|
||||
notifyReady();
|
||||
});
|
||||
vkPlayer.on('error', function () {
|
||||
notifyError();
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
''';
|
||||
|
||||
/// Внешний скрипт VK VideoPlayer API.
|
||||
static const vkVideoPlayerApiSrc = 'https://vk.ru/js/api/videoplayer.js';
|
||||
|
||||
/// Собирает inline `<script>` с общим bootstrap + опциональным VK-хендлером.
|
||||
static String bootstrap({required bool includeVk}) {
|
||||
final vkPart = includeVk ? vkPlayerReadyHandler : '';
|
||||
return '''
|
||||
(function () {
|
||||
$notifyReady
|
||||
$notifyError
|
||||
$rutubeCanPlayListener
|
||||
$vkPart
|
||||
})();
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
/// Embed-видео в ленте.
|
||||
///
|
||||
/// 1) Пока пользователь не нажал play — только превью (без WebView).
|
||||
/// 2) После play — превью + лоадер, WebView грузится скрыто.
|
||||
/// 3) Когда embed готов — показываем WebView и запускаем воспроизведение.
|
||||
///
|
||||
/// Реализовано на `flutter_inappwebview` — в отличие от `webview_flutter` v4
|
||||
/// нативно поддерживает fullscreen внутри embed-плеера (Rutube/VK/YouTube):
|
||||
/// Android `WebChromeClient.onShowCustomView/onHideCustomView`.
|
||||
class FeedVideoPlayer extends StatefulWidget {
|
||||
const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb});
|
||||
|
||||
final String embedUrl;
|
||||
|
||||
/// Пока бэкенд не отдаёт превью — null, показывается серая заглушка.
|
||||
final String? videoUrlThumb;
|
||||
|
||||
@override
|
||||
State<FeedVideoPlayer> createState() => _FeedVideoPlayerState();
|
||||
}
|
||||
|
||||
class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
|
||||
static const Duration _readyFallbackDelay = Duration(seconds: 4);
|
||||
|
||||
/// Пока null — WebView не рендерится. Заполняется по жесту play.
|
||||
InAppWebViewInitialData? _initialData;
|
||||
bool _isLoading = false;
|
||||
bool _isReady = false;
|
||||
bool _hasError = false;
|
||||
|
||||
/// Страховка готовности для провайдеров без сигнала ready (Rutube/VK).
|
||||
Timer? _readyFallback;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FeedVideoPlayer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) {
|
||||
_reset();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_readyFallback?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = null;
|
||||
_initialData = null;
|
||||
_isLoading = false;
|
||||
_isReady = false;
|
||||
_hasError = false;
|
||||
}
|
||||
|
||||
void _markReady() {
|
||||
if (!mounted || _isReady || _hasError) return;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = null;
|
||||
setState(() {
|
||||
_isReady = true;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _markError() {
|
||||
if (!mounted || _isReady) return;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = null;
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_isLoading = false;
|
||||
_initialData = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onPlayPressed() async {
|
||||
if (_isLoading || _isReady || !mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_hasError = false;
|
||||
});
|
||||
|
||||
try {
|
||||
final embedUri = FeedVideoEmbed.uriForPlayback(widget.embedUrl);
|
||||
final baseUrl = '${embedUri.scheme}://${embedUri.host}/';
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_initialData = InAppWebViewInitialData(
|
||||
data: FeedVideoEmbed.buildHtml(embedUri.toString()),
|
||||
mimeType: 'text/html',
|
||||
encoding: 'utf8',
|
||||
baseUrl: WebUri(baseUrl),
|
||||
);
|
||||
});
|
||||
} catch (e, st) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('FeedVideoPlayer load failed: $e\n$st');
|
||||
}
|
||||
_markError();
|
||||
}
|
||||
}
|
||||
|
||||
InAppWebViewSettings get _settings => InAppWebViewSettings(
|
||||
// Жест play уже был на кнопке — разрешаем старта без доп. тапа.
|
||||
mediaPlaybackRequiresUserGesture: false,
|
||||
allowsInlineMediaPlayback: true,
|
||||
iframeAllow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen',
|
||||
iframeAllowFullscreen: true,
|
||||
transparentBackground: false,
|
||||
);
|
||||
|
||||
void _onWebViewCreated(InAppWebViewController controller) {
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: FeedVideoEmbedScripts.readyChannel,
|
||||
callback: (args) {
|
||||
final status = args.isNotEmpty ? args.first?.toString() : 'ready';
|
||||
if (status == 'error') {
|
||||
_markError();
|
||||
} else {
|
||||
_markReady();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _onLoadStop(InAppWebViewController _, WebUri? _) {
|
||||
// Если провайдер не шлёт сигнал готовности (Rutube `player:canPlay` / VK `started`),
|
||||
// покажем WebView по таймауту после полной загрузки (напр. YouTube / OK).
|
||||
if (_isReady || _hasError || !mounted) return;
|
||||
_readyFallback?.cancel();
|
||||
_readyFallback = Timer(_readyFallbackDelay, () {
|
||||
if (mounted && !_isReady && !_hasError) _markReady();
|
||||
});
|
||||
}
|
||||
|
||||
void _onReceivedError(InAppWebViewController _, WebResourceRequest request, WebResourceError error) {
|
||||
if (!mounted) return;
|
||||
if (request.isForMainFrame != true) return;
|
||||
// ERR_BLOCKED_BY_ORB — cross-origin блокировки subframe, не реальная ошибка плеера.
|
||||
if (error.description.contains('ERR_BLOCKED_BY_ORB')) return;
|
||||
if (_isReady) return;
|
||||
if (kDebugMode) debugPrint('FeedVideoPlayer error: ${error.description}');
|
||||
_markError();
|
||||
}
|
||||
|
||||
void _onEnterFullscreen(InAppWebViewController _) {
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
|
||||
}
|
||||
|
||||
void _onExitFullscreen(InAppWebViewController _) {
|
||||
// Возвращаем системный набор ориентаций (не блокируем глобально в portrait).
|
||||
SystemChrome.setPreferredOrientations([]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final player = ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// WebView создаётся только после play и до ready остаётся скрытым под превью.
|
||||
if (_initialData != null)
|
||||
Opacity(
|
||||
opacity: _isReady ? 1 : 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !_isReady,
|
||||
child: InAppWebView(
|
||||
key: ValueKey('feed-inappwebview-${widget.embedUrl}'),
|
||||
initialData: _initialData,
|
||||
initialSettings: _settings,
|
||||
onWebViewCreated: _onWebViewCreated,
|
||||
onConsoleMessage: (_, message) => log('FeedVideoPlayer console message: ${message.message}'),
|
||||
onLoadStop: _onLoadStop,
|
||||
onReceivedError: _onReceivedError,
|
||||
onEnterFullscreen: _onEnterFullscreen,
|
||||
onExitFullscreen: _onExitFullscreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isReady)
|
||||
FeedVideoPreviewPlaceholder(
|
||||
previewUrl: widget.videoUrlThumb,
|
||||
isLoading: _isLoading,
|
||||
hasError: _hasError,
|
||||
onPlay: _onPlayPressed,
|
||||
onRetry: _onPlayPressed,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Пока превью/лоадер — не даём InkWell карточки перехватывать тап.
|
||||
if (_isReady) return player;
|
||||
return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FeedVideoPreviewPlaceholder extends StatelessWidget {
|
||||
const FeedVideoPreviewPlaceholder({
|
||||
super.key,
|
||||
required this.previewUrl,
|
||||
required this.isLoading,
|
||||
required this.hasError,
|
||||
required this.onPlay,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String? previewUrl;
|
||||
final bool isLoading;
|
||||
final bool hasError;
|
||||
final VoidCallback onPlay;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const ColoredBox(color: Colors.black),
|
||||
if (previewUrl != null && previewUrl!.isNotEmpty)
|
||||
CachedNetworkImage(
|
||||
imageUrl: previewUrl!,
|
||||
fit: BoxFit.contain,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)),
|
||||
)
|
||||
else
|
||||
const ColoredBox(color: Color(0xFF2C2C2C)),
|
||||
if (hasError)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 40, color: Colors.white54),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: onRetry,
|
||||
child: const Text('Повторить', style: TextStyle(color: Colors.white70)),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (isLoading)
|
||||
const Center(child: CircularProgressIndicator(color: Colors.white))
|
||||
else
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onTap: onPlay,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
||||
@@ -116,9 +116,7 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText: widget.plan.details.length > 200
|
||||
? '${widget.plan.details.substring(0, 200)}...'
|
||||
: widget.plan.details,
|
||||
markdownText: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details,
|
||||
textColor: appTheme.black,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:eatvio_app/features/feed/models/comment_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_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||
import 'package:eatvio_app/features/feed/services/feed_service.dart';
|
||||
@@ -13,9 +13,12 @@ class FeedRepository {
|
||||
|
||||
FeedRepository({required this.feedService});
|
||||
|
||||
Future<FeedBlogPostSettingsModel> getFeedBlogPosts({String? query, int page = 1}) async {
|
||||
return (await feedService.getBlogPostList(page: page, query: query));
|
||||
|
||||
Future<({List<FeedBlogPostModel> posts, String? nextCursor})> getFeed({String? search, String? cursor, int? currentUserId}) async {
|
||||
final response = await feedService.getFeed(search: search, cursor: cursor);
|
||||
return (
|
||||
posts: response.data.map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId)).toList(),
|
||||
nextCursor: response.meta.nextCursor,
|
||||
);
|
||||
}
|
||||
|
||||
Future<FeedBlogPostModel> getBlogPost({required int id}) async {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
@@ -18,7 +19,13 @@ abstract class FeedService {
|
||||
@GET('/api/blogPosts/index')
|
||||
Future<FeedBlogPostSettingsModel> getBlogPostList({@Query('page') required int page, @Query('query_search') String? query});
|
||||
|
||||
@GET('/api/blogPosts/show/{postId}')
|
||||
@GET('/api/feed')
|
||||
Future<PostFeedPaginationModel> getFeed({
|
||||
@Query('search') String? search,
|
||||
@Query('cursor') String? cursor,
|
||||
});
|
||||
|
||||
@GET('/api/v2/blogPosts/show/{postId}')
|
||||
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
|
||||
|
||||
@GET('/api/recipes/index')
|
||||
|
||||
15
lib/features/payment/models/promo_applied_list_response.dart
Normal file
15
lib/features/payment/models/promo_applied_list_response.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'promo_applied_list_response.freezed.dart';
|
||||
part 'promo_applied_list_response.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PromoAppliedListResponse with _$PromoAppliedListResponse {
|
||||
const factory PromoAppliedListResponse({
|
||||
@JsonKey(name: 'promo_codes') required List<PromoCodeModel> promoCodes,
|
||||
}) = _PromoAppliedListResponse;
|
||||
|
||||
factory PromoAppliedListResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$PromoAppliedListResponseFromJson(json);
|
||||
}
|
||||
21
lib/features/payment/models/promo_apply_response.dart
Normal file
21
lib/features/payment/models/promo_apply_response.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'promo_apply_response.freezed.dart';
|
||||
part 'promo_apply_response.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PromoApplyResponse with _$PromoApplyResponse {
|
||||
const factory PromoApplyResponse({
|
||||
required bool success,
|
||||
String? error,
|
||||
@JsonKey(name: 'promo_code') PromoCodeModel? promoCode,
|
||||
@JsonKey(name: 'discount_percent') int? discountPercent,
|
||||
@JsonKey(name: 'discount_amount_rub') double? discountAmountRub,
|
||||
@JsonKey(name: 'original_amount_rub') double? originalAmountRub,
|
||||
@JsonKey(name: 'final_amount_rub') double? finalAmountRub,
|
||||
}) = _PromoApplyResponse;
|
||||
|
||||
factory PromoApplyResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$PromoApplyResponseFromJson(json);
|
||||
}
|
||||
22
lib/features/payment/models/promo_code_model.dart
Normal file
22
lib/features/payment/models/promo_code_model.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'promo_code_model.freezed.dart';
|
||||
part 'promo_code_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PromoCodeModel with _$PromoCodeModel {
|
||||
const factory PromoCodeModel({
|
||||
required int id,
|
||||
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
|
||||
@JsonKey(name: 'promo_code') required String promoCode,
|
||||
@JsonKey(name: 'discount_percent') required int discountPercent,
|
||||
@JsonKey(name: 'discount_amount_rub') required double discountAmountRub,
|
||||
required String state,
|
||||
@JsonKey(name: 'applied_at') String? appliedAt,
|
||||
@JsonKey(name: 'confirmed_at') String? confirmedAt,
|
||||
@JsonKey(name: 'cancelled_at') String? cancelledAt,
|
||||
}) = _PromoCodeModel;
|
||||
|
||||
factory PromoCodeModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$PromoCodeModelFromJson(json);
|
||||
}
|
||||
15
lib/features/payment/models/promo_remove_response.dart
Normal file
15
lib/features/payment/models/promo_remove_response.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'promo_remove_response.freezed.dart';
|
||||
part 'promo_remove_response.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PromoRemoveResponse with _$PromoRemoveResponse {
|
||||
const factory PromoRemoveResponse({
|
||||
required bool success,
|
||||
String? error,
|
||||
}) = _PromoRemoveResponse;
|
||||
|
||||
factory PromoRemoveResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$PromoRemoveResponseFromJson(json);
|
||||
}
|
||||
20
lib/features/payment/models/promo_validate_response.dart
Normal file
20
lib/features/payment/models/promo_validate_response.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'promo_validate_response.freezed.dart';
|
||||
part 'promo_validate_response.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PromoValidateResponse with _$PromoValidateResponse {
|
||||
const factory PromoValidateResponse({
|
||||
required bool valid,
|
||||
String? error,
|
||||
@JsonKey(name: 'promo_code') String? promoCode,
|
||||
@JsonKey(name: 'discount_percent') int? discountPercent,
|
||||
@JsonKey(name: 'discount_amount_rub') double? discountAmountRub,
|
||||
@JsonKey(name: 'original_amount_rub') double? originalAmountRub,
|
||||
@JsonKey(name: 'final_amount_rub') double? finalAmountRub,
|
||||
}) = _PromoValidateResponse;
|
||||
|
||||
factory PromoValidateResponse.fromJson(Map<String, dynamic> json) =>
|
||||
_$PromoValidateResponseFromJson(json);
|
||||
}
|
||||
@@ -1,22 +1,73 @@
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Оркестрирует флоу оплаты консультации:
|
||||
/// гейт «У вас есть промокод?» → (Да → форма промокода) → SbpBanks.
|
||||
/// applied rehydrate → (или гейт → форма) → apply при необходимости → SbpBanks.
|
||||
Future<void> showConsultationPaymentFlow(
|
||||
BuildContext context, {
|
||||
required int planId,
|
||||
Future<bool?> Function(BuildContext)? showGate,
|
||||
Future<PromoCodeResult?> Function(BuildContext, {Future<PromoValidation> Function(String)? onValidate})? showForm,
|
||||
Future<PromoCodeResult?> Function(
|
||||
BuildContext, {
|
||||
Future<PromoValidation> Function(String)? onValidate,
|
||||
Future<bool> Function()? onRemove,
|
||||
PromoCodeModel? initialPromo,
|
||||
})? showForm,
|
||||
Future<void> Function(BuildContext, {required int planId})? showSbp,
|
||||
Future<PromoValidateResponse> Function({
|
||||
required int planId,
|
||||
required String promoCode,
|
||||
})? validatePromo,
|
||||
Future<PromoApplyResponse> Function({
|
||||
required int planId,
|
||||
required String promoCode,
|
||||
})? applyPromo,
|
||||
Future<PromoCodeModel?> Function(int planId)? listApplied,
|
||||
Future<bool> Function(int planId)? removePromo,
|
||||
}) async {
|
||||
final gate = showGate ?? _showGate;
|
||||
final form = showForm ?? _showForm;
|
||||
final sbp = showSbp ?? _showSbp;
|
||||
final s = S.of(context);
|
||||
final validate = validatePromo ?? _defaultValidatePromo;
|
||||
final apply = applyPromo ?? _defaultApplyPromo;
|
||||
final findApplied = listApplied ?? _defaultFindApplied;
|
||||
final remove = removePromo ?? _defaultRemovePromo;
|
||||
|
||||
final existing = await findApplied(planId);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (existing != null) {
|
||||
final formResult = await form(
|
||||
context,
|
||||
initialPromo: existing,
|
||||
onRemove: () => remove(planId),
|
||||
onValidate: (code) => _mapValidateResponse(
|
||||
validate(planId: planId, promoCode: code),
|
||||
),
|
||||
);
|
||||
if (formResult == null || !context.mounted) return;
|
||||
|
||||
if (formResult.hasPromoCode && !formResult.alreadyApplied) {
|
||||
final applied = await _tryApplyPromo(
|
||||
apply: apply,
|
||||
planId: planId,
|
||||
promoCode: formResult.code!,
|
||||
);
|
||||
if (!applied || !context.mounted) return;
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
await sbp(context, planId: planId);
|
||||
return;
|
||||
}
|
||||
|
||||
final gateChoice = await gate(context);
|
||||
if (gateChoice == null || !context.mounted) return;
|
||||
@@ -24,9 +75,20 @@ Future<void> showConsultationPaymentFlow(
|
||||
if (gateChoice) {
|
||||
final formResult = await form(
|
||||
context,
|
||||
onValidate: (_) async => PromoValidation.failure(s.promo_invalid),
|
||||
onValidate: (code) => _mapValidateResponse(
|
||||
validate(planId: planId, promoCode: code),
|
||||
),
|
||||
);
|
||||
if (formResult == null || !context.mounted) return;
|
||||
|
||||
if (formResult.hasPromoCode && !formResult.alreadyApplied) {
|
||||
final applied = await _tryApplyPromo(
|
||||
apply: apply,
|
||||
planId: planId,
|
||||
promoCode: formResult.code!,
|
||||
);
|
||||
if (!applied || !context.mounted) return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
@@ -40,10 +102,96 @@ Future<bool?> _showGate(BuildContext context) {
|
||||
Future<PromoCodeResult?> _showForm(
|
||||
BuildContext context, {
|
||||
Future<PromoValidation> Function(String)? onValidate,
|
||||
Future<bool> Function()? onRemove,
|
||||
PromoCodeModel? initialPromo,
|
||||
}) {
|
||||
return ItvioPromoCodeModal.show(context, onValidate: onValidate);
|
||||
return ItvioPromoCodeModal.show(
|
||||
context,
|
||||
onValidate: onValidate,
|
||||
onRemove: onRemove,
|
||||
initialPromo: initialPromo,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showSbp(BuildContext context, {required int planId}) {
|
||||
return showSbpBanksBottomSheet(context, planId: planId);
|
||||
}
|
||||
|
||||
Future<PromoValidateResponse> _defaultValidatePromo({
|
||||
required int planId,
|
||||
required String promoCode,
|
||||
}) {
|
||||
return sl<PaymentRepository>().validatePromoCode(
|
||||
planId: planId,
|
||||
promoCode: promoCode,
|
||||
);
|
||||
}
|
||||
|
||||
Future<PromoApplyResponse> _defaultApplyPromo({
|
||||
required int planId,
|
||||
required String promoCode,
|
||||
}) {
|
||||
return sl<PaymentRepository>().applyPromoCode(
|
||||
planId: planId,
|
||||
promoCode: promoCode,
|
||||
);
|
||||
}
|
||||
|
||||
Future<PromoCodeModel?> _defaultFindApplied(int planId) {
|
||||
return sl<PaymentRepository>().findAppliedPromoForPlan(planId);
|
||||
}
|
||||
|
||||
Future<bool> _defaultRemovePromo(int planId) async {
|
||||
try {
|
||||
final response = await sl<PaymentRepository>().removePromoCode(planId: planId);
|
||||
if (response.success) return true;
|
||||
showErrorToast(response.error ?? 'Не удалось убрать промокод.');
|
||||
return false;
|
||||
} catch (_) {
|
||||
showErrorToast('Не удалось убрать промокод. Попробуйте позже');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<PromoValidation> _mapValidateResponse(
|
||||
Future<PromoValidateResponse> request,
|
||||
) async {
|
||||
try {
|
||||
final response = await request;
|
||||
if (!response.valid) {
|
||||
return PromoValidation.failure(
|
||||
response.error ?? 'Промокод невалиден.',
|
||||
);
|
||||
}
|
||||
return PromoValidation.success(
|
||||
discountPercent: response.discountPercent,
|
||||
discountAmountRub: response.discountAmountRub,
|
||||
originalAmountRub: response.originalAmountRub,
|
||||
finalAmountRub: response.finalAmountRub,
|
||||
);
|
||||
} catch (_) {
|
||||
return const PromoValidation.failure(
|
||||
'Не удалось проверить промокод. Попробуйте позже',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _tryApplyPromo({
|
||||
required Future<PromoApplyResponse> Function({
|
||||
required int planId,
|
||||
required String promoCode,
|
||||
}) apply,
|
||||
required int planId,
|
||||
required String promoCode,
|
||||
}) async {
|
||||
try {
|
||||
final response = await apply(planId: planId, promoCode: promoCode);
|
||||
if (response.success) return true;
|
||||
|
||||
showErrorToast(response.error ?? 'Не удалось применить промокод.');
|
||||
return false;
|
||||
} catch (_) {
|
||||
showErrorToast('Не удалось применить промокод. Попробуйте позже');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,87 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Результат работы модалки промокода.
|
||||
class PromoCodeResult {
|
||||
const PromoCodeResult({this.code});
|
||||
const PromoCodeResult({
|
||||
this.code,
|
||||
this.discountPercent,
|
||||
this.discountAmountRub,
|
||||
this.originalAmountRub,
|
||||
this.finalAmountRub,
|
||||
this.alreadyApplied = false,
|
||||
});
|
||||
|
||||
/// Применённый промокод или null, если пользователь продолжил без него.
|
||||
final String? code;
|
||||
final int? discountPercent;
|
||||
final double? discountAmountRub;
|
||||
final double? originalAmountRub;
|
||||
final double? finalAmountRub;
|
||||
|
||||
/// true — промокод уже на сервере (rehydrate Continue), apply не нужен.
|
||||
final bool alreadyApplied;
|
||||
|
||||
bool get hasPromoCode => code != null;
|
||||
}
|
||||
|
||||
/// Ответ проверки промокода.
|
||||
class PromoValidation {
|
||||
const PromoValidation.success() : error = null;
|
||||
const PromoValidation.failure(this.error);
|
||||
const PromoValidation.success({
|
||||
this.discountPercent,
|
||||
this.discountAmountRub,
|
||||
this.originalAmountRub,
|
||||
this.finalAmountRub,
|
||||
}) : error = null;
|
||||
|
||||
const PromoValidation.failure(this.error)
|
||||
: discountPercent = null,
|
||||
discountAmountRub = null,
|
||||
originalAmountRub = null,
|
||||
finalAmountRub = null;
|
||||
|
||||
final String? error;
|
||||
final int? discountPercent;
|
||||
final double? discountAmountRub;
|
||||
final double? originalAmountRub;
|
||||
final double? finalAmountRub;
|
||||
|
||||
bool get isValid => error == null;
|
||||
}
|
||||
|
||||
/// Модальное окно ввода промокода.
|
||||
/// Модальное окно ввода / просмотра применённого промокода.
|
||||
class ItvioPromoCodeModal extends StatefulWidget {
|
||||
const ItvioPromoCodeModal({super.key, this.onValidate});
|
||||
const ItvioPromoCodeModal({
|
||||
super.key,
|
||||
this.onValidate,
|
||||
this.onRemove,
|
||||
this.initialPromo,
|
||||
});
|
||||
|
||||
final Future<PromoValidation> Function(String code)? onValidate;
|
||||
final Future<bool> Function()? onRemove;
|
||||
final PromoCodeModel? initialPromo;
|
||||
|
||||
static Future<PromoCodeResult?> show(
|
||||
BuildContext context, {
|
||||
Future<PromoValidation> Function(String code)? onValidate,
|
||||
Future<bool> Function()? onRemove,
|
||||
PromoCodeModel? initialPromo,
|
||||
}) {
|
||||
return showModalBottomSheet<PromoCodeResult>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => ItvioPromoCodeModal(onValidate: onValidate),
|
||||
builder: (_) => ItvioPromoCodeModal(
|
||||
onValidate: onValidate,
|
||||
onRemove: onRemove,
|
||||
initialPromo: initialPromo,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,14 +89,32 @@ class ItvioPromoCodeModal extends StatefulWidget {
|
||||
State<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState();
|
||||
}
|
||||
|
||||
enum _ModalState { input, loading }
|
||||
enum _ModalState { input, loading, applied }
|
||||
|
||||
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||
final _controller = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
|
||||
_ModalState _state = _ModalState.input;
|
||||
late _ModalState _state;
|
||||
String? _errorText;
|
||||
int? _discountPercent;
|
||||
double? _discountAmountRub;
|
||||
String? _appliedCode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initial = widget.initialPromo;
|
||||
if (initial != null) {
|
||||
_state = _ModalState.applied;
|
||||
_appliedCode = initial.promoCode;
|
||||
_discountPercent = initial.discountPercent;
|
||||
_discountAmountRub = initial.discountAmountRub;
|
||||
_controller.text = initial.promoCode;
|
||||
} else {
|
||||
_state = _ModalState.input;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -98,7 +158,62 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop(PromoCodeResult(code: code));
|
||||
Navigator.of(context).pop(
|
||||
PromoCodeResult(
|
||||
code: code,
|
||||
discountPercent: validation.discountPercent,
|
||||
discountAmountRub: validation.discountAmountRub,
|
||||
originalAmountRub: validation.originalAmountRub,
|
||||
finalAmountRub: validation.finalAmountRub,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onContinueApplied() {
|
||||
Navigator.of(context).pop(
|
||||
PromoCodeResult(
|
||||
code: _appliedCode,
|
||||
discountPercent: _discountPercent,
|
||||
discountAmountRub: _discountAmountRub,
|
||||
alreadyApplied: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRemove() async {
|
||||
setState(() {
|
||||
_state = _ModalState.loading;
|
||||
_errorText = null;
|
||||
});
|
||||
|
||||
var success = true;
|
||||
try {
|
||||
if (widget.onRemove != null) {
|
||||
success = await widget.onRemove!();
|
||||
}
|
||||
} catch (_) {
|
||||
success = false;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (!success) {
|
||||
setState(() {
|
||||
_state = _ModalState.applied;
|
||||
_errorText = S.of(context).promo_remove_failed;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_state = _ModalState.input;
|
||||
_appliedCode = null;
|
||||
_discountPercent = null;
|
||||
_discountAmountRub = null;
|
||||
_errorText = null;
|
||||
_controller.clear();
|
||||
});
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _onSkip() {
|
||||
@@ -107,6 +222,10 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||
|
||||
bool get _isLoading => _state == _ModalState.loading;
|
||||
|
||||
/// Applied UI while loading remove (state=loading, but promo still shown).
|
||||
bool get _isApplied =>
|
||||
_appliedCode != null && _state != _ModalState.input;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
@@ -127,40 +246,51 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||
children: [
|
||||
const Center(child: BottomSheetHandlerBar()),
|
||||
const SizedBox(height: 16),
|
||||
_buildHeader(),
|
||||
_buildHeader(s),
|
||||
if (_isApplied) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
s.promo_applied_subtitle,
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF6B7684)),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Промокод',
|
||||
style: TextStyle(
|
||||
Text(
|
||||
s.promo_code,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF6B7684),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildField(),
|
||||
_buildField(s),
|
||||
const SizedBox(height: 8),
|
||||
_buildHint(),
|
||||
_buildHint(s),
|
||||
const SizedBox(height: 12),
|
||||
_buildApplyButton(s),
|
||||
const SizedBox(height: 6),
|
||||
_buildSkipButton(s),
|
||||
if (_isApplied) ...[
|
||||
_buildContinueButton(s),
|
||||
const SizedBox(height: 6),
|
||||
_buildRemoveButton(s),
|
||||
] else ...[
|
||||
_buildApplyButton(s),
|
||||
const SizedBox(height: 6),
|
||||
_buildSkipButton(s),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
final s = S.of(context);
|
||||
Widget _buildHeader(S s) {
|
||||
return Text(
|
||||
s.have_promo_code_form,
|
||||
_isApplied ? s.promo_applied_title : s.have_promo_code_form,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField() {
|
||||
final hasError = _errorText != null;
|
||||
final s = S.of(context);
|
||||
Widget _buildField(S s) {
|
||||
final hasError = _errorText != null && !_isApplied;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
@@ -171,7 +301,7 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||
width: 2,
|
||||
color: hasError
|
||||
? appTheme.red
|
||||
: (_focusNode.hasFocus
|
||||
: (_focusNode.hasFocus && !_isApplied
|
||||
? appTheme.blueA400
|
||||
: Colors.transparent),
|
||||
),
|
||||
@@ -182,14 +312,16 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: !_isLoading,
|
||||
enabled: !_isLoading && !_isApplied,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
onChanged: (_) {
|
||||
if (_errorText != null) {
|
||||
setState(() => _errorText = null);
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) => _apply(),
|
||||
onSubmitted: (_) {
|
||||
if (!_isApplied) _apply();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: s.enter_promo_code,
|
||||
hintStyle: const TextStyle(
|
||||
@@ -202,9 +334,11 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF24303F),
|
||||
color: _isApplied
|
||||
? const Color(0xFF6B7684)
|
||||
: const Color(0xFF24303F),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -213,13 +347,17 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHint() {
|
||||
Widget _buildHint(S s) {
|
||||
final String text;
|
||||
final Color color;
|
||||
|
||||
if (_errorText != null) {
|
||||
text = _errorText!;
|
||||
color = appTheme.red;
|
||||
} else if (_isApplied && _discountPercent != null) {
|
||||
final amount = (_discountAmountRub ?? 0).toStringAsFixed(0);
|
||||
text = s.promo_discount_hint(_discountPercent!, amount);
|
||||
color = const Color(0xFF27A06B);
|
||||
} else {
|
||||
text = '';
|
||||
color = Colors.transparent;
|
||||
@@ -264,6 +402,50 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContinueButton(S s) {
|
||||
return SizedBox(
|
||||
height: 50,
|
||||
child: FilledButton(
|
||||
onPressed: _isLoading ? null : _onContinueApplied,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: appTheme.blueA400,
|
||||
foregroundColor: appTheme.whiteA700,
|
||||
disabledBackgroundColor: appTheme.blueA400.withValues(alpha: 0.55),
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Text(s.continue_),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRemoveButton(S s) {
|
||||
return SizedBox(
|
||||
height: 46,
|
||||
child: TextButton(
|
||||
onPressed: _isLoading ? null : _onRemove,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: appTheme.blueA400,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(fontSize: 14),
|
||||
),
|
||||
child: _isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
)
|
||||
: Text(s.promo_remove),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkipButton(S s) {
|
||||
return SizedBox(
|
||||
height: 46,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||
import 'package:eatvio_app/features/chat/services/chat_service.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||
|
||||
/// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП.
|
||||
class PaymentRepository {
|
||||
@@ -15,4 +21,67 @@ class PaymentRepository {
|
||||
body: const {'payment_provider': 'alfa-bank'},
|
||||
);
|
||||
}
|
||||
|
||||
/// Список applied-промокодов текущего пользователя.
|
||||
Future<PromoAppliedListResponse> listAppliedPromoCodes() {
|
||||
return chatService.listAppliedPromoCodes();
|
||||
}
|
||||
|
||||
/// Applied-промокод для конкретного chosen_plan, либо null (нет / ошибка сети).
|
||||
Future<PromoCodeModel?> findAppliedPromoForPlan(int planId) async {
|
||||
try {
|
||||
final response = await listAppliedPromoCodes();
|
||||
for (final promo in response.promoCodes) {
|
||||
if (promo.chosenPlanId == planId && promo.state == 'applied') {
|
||||
return promo;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Preview-валидация промокода без применения.
|
||||
Future<PromoValidateResponse> validatePromoCode({
|
||||
required int planId,
|
||||
required String promoCode,
|
||||
}) {
|
||||
return chatService.validatePromoCode(
|
||||
id: planId,
|
||||
body: {'promo_code': promoCode},
|
||||
);
|
||||
}
|
||||
|
||||
/// Применяет промокод к chosen_plan. При HTTP 422 возвращает тело ответа.
|
||||
Future<PromoApplyResponse> applyPromoCode({
|
||||
required int planId,
|
||||
required String promoCode,
|
||||
}) async {
|
||||
try {
|
||||
return await chatService.applyPromoCode(
|
||||
id: planId,
|
||||
body: {'promo_code': promoCode},
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
final data = e.response?.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
return PromoApplyResponse.fromJson(data);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Удаляет применённый промокод (idempotent).
|
||||
Future<PromoRemoveResponse> removePromoCode({required int planId}) async {
|
||||
try {
|
||||
return await chatService.removePromoCode(id: planId);
|
||||
} on DioException catch (e) {
|
||||
final data = e.response?.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
return PromoRemoveResponse.fromJson(data);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
final ConsultantRepository consultantRepository;
|
||||
String _currentPeriod = '1';
|
||||
|
||||
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
|
||||
static ProfileCubit? myProfileCubit;
|
||||
|
||||
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
|
||||
|
||||
Future<void> getProfileBySlug(String slug) async {
|
||||
@@ -49,6 +52,7 @@ class ProfileCubit extends Cubit<ProfileState> {
|
||||
}
|
||||
|
||||
Future<void> getMyProfile({String period = '1'}) async {
|
||||
myProfileCubit = this;
|
||||
emit(const ProfileState.loading());
|
||||
_currentPeriod = period;
|
||||
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) {
|
||||
List<FeedBlogPostModel> feedBlogPostList = [...(state as ProfileStateLoaded).blogPosts];
|
||||
final int index = feedBlogPostList.indexOf(feedBlogPostModel);
|
||||
|
||||
@@ -436,8 +436,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('blogPostCreateScreen');
|
||||
onTap: () async {
|
||||
await GoRouter.of(context).pushNamed('blogPostCreateScreen');
|
||||
if (mounted) {
|
||||
await profileCubit.reloadBlogPosts();
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Ink(
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.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/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
@@ -22,7 +24,11 @@ class ProfileBlogPostWidget extends StatefulWidget {
|
||||
final int index;
|
||||
final ProfileCubit profileCubit;
|
||||
|
||||
const ProfileBlogPostWidget({super.key, required this.index, required this.profileCubit});
|
||||
const ProfileBlogPostWidget({
|
||||
super.key,
|
||||
required this.index,
|
||||
required this.profileCubit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProfileBlogPostWidget> createState() => _ProfileBlogPostWidgetState();
|
||||
@@ -31,221 +37,289 @@ class ProfileBlogPostWidget extends StatefulWidget {
|
||||
class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
late bool isLiked;
|
||||
late int totalLikes;
|
||||
int currentGalleryIndex = 0;
|
||||
|
||||
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ProfileCubit, ProfileState>(
|
||||
bloc: widget.profileCubit,
|
||||
builder: (BuildContext context, ProfileState state) {
|
||||
if (state is ProfileStateLoaded) {
|
||||
isLiked = state.blogPosts[widget.index].wasLikedByMe;
|
||||
totalLikes = state.blogPosts[widget.index].totalLikes;
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (state is! ProfileStateLoaded) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final FeedBlogPostModel post = state.blogPosts[widget.index];
|
||||
isLiked = post.wasLikedByMe;
|
||||
totalLikes = post.totalLikes;
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () =>
|
||||
GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: NetworkImage(
|
||||
post.author.authorProfileIcon.contains('https')
|
||||
? post.author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl +
|
||||
post.author.authorProfileIcon,
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.author.authorName,
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
CustomPopup(
|
||||
isAuthor: post.author.authorIsMe,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${post.id}',
|
||||
id: post.id,
|
||||
feedBlogPostModel: post,
|
||||
userSlug: post.author.authorSlug,
|
||||
onPostsChanged: widget.profileCubit.reloadBlogPosts,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: NetworkImage(
|
||||
state.blogPosts[widget.index].author.authorProfileIcon.contains('https')
|
||||
? state.blogPosts[widget.index].author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl + state.blogPosts[widget.index].author.authorProfileIcon,
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.blogPosts[widget.index].author.authorName,
|
||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(state.blogPosts[widget.index].createdAt)} в ${state.blogPosts[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.blogPosts[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(height: 1, color: Colors.black45),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
CustomPopup(
|
||||
isAuthor: state.blogPosts[widget.index].author.authorIsMe,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${(state.blogPosts[widget.index].id)}',
|
||||
id: state.blogPosts[widget.index].id,
|
||||
feedBlogPostModel: state.blogPosts[widget.index],
|
||||
userSlug: state.blogPosts[widget.index].author.authorSlug,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state.blogPosts[widget.index].title.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
state.blogPosts[widget.index].title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87),
|
||||
child: Text(
|
||||
post.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (state.blogPosts[widget.index].title.isEmpty) const SizedBox(height: 8),
|
||||
if (state.blogPosts[widget.index].title.isNotEmpty && state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
|
||||
if (state.blogPosts[widget.index].description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
String description = state.blogPosts[widget.index].description;
|
||||
final isLong = description.length > 200;
|
||||
|
||||
if (isLong) {
|
||||
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
|
||||
int cutPoint = 200;
|
||||
|
||||
// Проверяем, есть ли незакрытые markdown теги
|
||||
String preview = description.substring(0, cutPoint);
|
||||
|
||||
// Считаем открывающие и закрывающие теги
|
||||
int boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||
bool hasUnclosedBold = boldOpen > boldClose;
|
||||
|
||||
// Если есть незакрытый bold, ищем закрытие
|
||||
if (hasUnclosedBold) {
|
||||
int searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||
int boldClosePos = description.indexOf('**', cutPoint);
|
||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||
cutPoint = boldClosePos + 2;
|
||||
} else {
|
||||
// Если не нашли, обрезаем до последнего пробела
|
||||
cutPoint = description.lastIndexOf(' ', 200);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
} else {
|
||||
// Обрезаем до последнего пробела для красоты
|
||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
|
||||
description = description.substring(0, cutPoint);
|
||||
}
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: description,
|
||||
fontSize: 16,
|
||||
textColor: Colors.black87,
|
||||
),
|
||||
],
|
||||
if (post.title.isEmpty) const SizedBox(height: 8),
|
||||
if (post.title.isNotEmpty && post.description.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
if (post.description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText: _truncateDescription(post.description),
|
||||
fontSize: 16,
|
||||
textColor: Colors.black87,
|
||||
),
|
||||
),
|
||||
if (post.description.length > 200)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'Показать ещё',
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: Color(0xFF8B8B8B),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb != null
|
||||
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
|
||||
: null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'profile-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'profile-blog-post-${post.id}-cover'
|
||||
: null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: isLiked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.8)
|
||||
: null,
|
||||
backgroundColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.1)
|
||||
: null,
|
||||
textColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.8)
|
||||
: null,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
isLiked = !isLiked;
|
||||
isLiked ? totalLikes++ : totalLikes--;
|
||||
});
|
||||
isLiked
|
||||
? await context.read<FeedCubit>().likePost(
|
||||
post.id,
|
||||
)
|
||||
: await context.read<FeedCubit>().unlikedPost(
|
||||
post.id,
|
||||
);
|
||||
widget.profileCubit.updateFeedBlogPostList(
|
||||
post,
|
||||
isLiked ? false : true,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state.blogPosts[widget.index].description.length > 200)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'Показать ещё',
|
||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (state.blogPosts[widget.index].title.isNotEmpty || state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}'], index: 0),
|
||||
);
|
||||
},
|
||||
child: Hero(
|
||||
tag: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
onTap: () async {
|
||||
setState(() {
|
||||
isLiked = !isLiked;
|
||||
isLiked ? totalLikes++ : totalLikes--;
|
||||
});
|
||||
isLiked
|
||||
? await context.read<FeedCubit>().likePost(state.blogPosts[widget.index].id)
|
||||
: await context.read<FeedCubit>().unlikedPost(state.blogPosts[widget.index].id);
|
||||
widget.profileCubit.updateFeedBlogPostList(state.blogPosts[widget.index], isLiked ? false : true);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
'assets/icons/comment.svg',
|
||||
height: 20,
|
||||
width: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
'assets/icons/comment.svg',
|
||||
height: 20,
|
||||
width: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.gray600,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
text: state.blogPosts[widget.index].totalComments.toString(),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
height: 20,
|
||||
width: 20,
|
||||
'assets/icons/share.svg',
|
||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||
text: post.totalComments.toString(),
|
||||
onTap: () => GoRouter.of(
|
||||
context,
|
||||
).pushNamed('blogPostScreen', extra: post.id),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FeedIconButton(
|
||||
svgIcon: SvgPicture.asset(
|
||||
height: 20,
|
||||
width: 20,
|
||||
'assets/icons/share.svg',
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.gray600,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.blogPosts[widget.index].id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse(
|
||||
'${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}',
|
||||
),
|
||||
);
|
||||
},
|
||||
sharePositionOrigin:
|
||||
box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
Icons.visibility_rounded,
|
||||
size: 16,
|
||||
color: appTheme.gray500,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
post.totalViews.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appTheme.gray500,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
state.blogPosts[widget.index].totalViews.toString(),
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _truncateDescription(String description) {
|
||||
final isLong = description.length > 200;
|
||||
if (!isLong) return description;
|
||||
|
||||
var cutPoint = 200;
|
||||
final preview = description.substring(0, cutPoint);
|
||||
final boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||
final hasUnclosedBold = boldOpen > boldClose;
|
||||
|
||||
if (hasUnclosedBold) {
|
||||
final searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||
final boldClosePos = description.indexOf('**', cutPoint);
|
||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||
cutPoint = boldClosePos + 2;
|
||||
} else {
|
||||
cutPoint = description.lastIndexOf(' ', 200);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
} else {
|
||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
|
||||
return description.substring(0, cutPoint);
|
||||
}
|
||||
|
||||
@@ -27,24 +27,27 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
static String m2(planName, name) =>
|
||||
"План «${planName}» с пользователем ${name} начался";
|
||||
|
||||
static String m3(step) => "шаг ${step} из 2";
|
||||
static String m3(percent, amount) =>
|
||||
"Discount ${percent}% — you save ${amount} ₽";
|
||||
|
||||
static String m4(planName, name) =>
|
||||
static String m4(step) => "шаг ${step} из 2";
|
||||
|
||||
static String m5(planName, name) =>
|
||||
"Завтра начинается план «${planName}» с пользователем ${name}";
|
||||
|
||||
static String m5(planName) =>
|
||||
static String m6(planName) =>
|
||||
"Завтра начнется прохождение плана «${planName}»";
|
||||
|
||||
static String m6(name, planName) =>
|
||||
static String m7(name, planName) =>
|
||||
"Пользователь ${name} отменил подписку на план «${planName}»";
|
||||
|
||||
static String m7(name, planName) =>
|
||||
static String m8(name, planName) =>
|
||||
"Пользователь ${name} оплатил план «${planName}»";
|
||||
|
||||
static String m8(name, planName) =>
|
||||
static String m9(name, planName) =>
|
||||
"Пользователь ${name} подписался на план «${planName}»";
|
||||
|
||||
static String m9(planName) => "Вы отменили подписку на план «${planName}»";
|
||||
static String m10(planName) => "Вы отменили подписку на план «${planName}»";
|
||||
|
||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||
@@ -715,10 +718,21 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Разница в ценах, это комиссия Итвио",
|
||||
),
|
||||
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
|
||||
"promo_applied_subtitle": MessageLookupByLibrary.simpleMessage(
|
||||
"The discount will be applied at checkout",
|
||||
),
|
||||
"promo_applied_title": MessageLookupByLibrary.simpleMessage(
|
||||
"Promo code applied!",
|
||||
),
|
||||
"promo_code": MessageLookupByLibrary.simpleMessage("Promo code"),
|
||||
"promo_discount_hint": m3,
|
||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||
"Promo code not found or expired",
|
||||
),
|
||||
"promo_remove": MessageLookupByLibrary.simpleMessage("Remove promo code"),
|
||||
"promo_remove_failed": MessageLookupByLibrary.simpleMessage(
|
||||
"Could not remove the promo code. Try again later",
|
||||
),
|
||||
"prostitution": MessageLookupByLibrary.simpleMessage("Проституция"),
|
||||
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
||||
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
|
||||
@@ -809,7 +823,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
|
||||
"Пошаговый фоторецепт",
|
||||
),
|
||||
"step_widget": m3,
|
||||
"step_widget": m4,
|
||||
"successfully_published": MessageLookupByLibrary.simpleMessage(
|
||||
"успешно опубликован",
|
||||
),
|
||||
@@ -834,8 +848,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"toOffer": MessageLookupByLibrary.simpleMessage("Предложить"),
|
||||
"to_draft": MessageLookupByLibrary.simpleMessage("В черновик"),
|
||||
"to_order": MessageLookupByLibrary.simpleMessage("Заказать"),
|
||||
"tommorowPlanWithUserWillStart": m4,
|
||||
"tomorrowYourPlanWillStart": m5,
|
||||
"tommorowPlanWithUserWillStart": m5,
|
||||
"tomorrowYourPlanWillStart": m6,
|
||||
"try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"),
|
||||
"try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"),
|
||||
"typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage(
|
||||
@@ -869,7 +883,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Верхние показатели давления:",
|
||||
),
|
||||
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
|
||||
"userCanceldThePlanSubscription": m6,
|
||||
"userCanceldThePlanSubscription": m7,
|
||||
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
|
||||
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
|
||||
),
|
||||
@@ -877,8 +891,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
MessageLookupByLibrary.simpleMessage(
|
||||
"Пользователь подтвердил прохождения плана.",
|
||||
),
|
||||
"userPaidThePlan": m7,
|
||||
"userSubscribedToPlan": m8,
|
||||
"userPaidThePlan": m8,
|
||||
"userSubscribedToPlan": m9,
|
||||
"user_authorization": MessageLookupByLibrary.simpleMessage(
|
||||
"Аутентификация",
|
||||
),
|
||||
@@ -1008,7 +1022,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"youCanComplain": MessageLookupByLibrary.simpleMessage(
|
||||
"Если вас что-то не устроило, вы можете подать жалобу",
|
||||
),
|
||||
"youCanceledSubscriptionToPlan": m9,
|
||||
"youCanceledSubscriptionToPlan": m10,
|
||||
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
|
||||
"Вы уверены, что хотите отменить заказ?",
|
||||
),
|
||||
|
||||
@@ -20,6 +20,9 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
|
||||
class MessageLookup extends MessageLookupByLibrary {
|
||||
String get localeName => 'ru';
|
||||
|
||||
static String m3(percent, amount) =>
|
||||
"Скидка ${percent}% — вы экономите ${amount} ₽";
|
||||
|
||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||
"Moscow": MessageLookupByLibrary.simpleMessage("Москва"),
|
||||
@@ -322,10 +325,21 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Разница в ценах, это комиссия Итвио",
|
||||
),
|
||||
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
|
||||
"promo_applied_subtitle": MessageLookupByLibrary.simpleMessage(
|
||||
"Скидка учтётся при оплате",
|
||||
),
|
||||
"promo_applied_title": MessageLookupByLibrary.simpleMessage(
|
||||
"Промокод применён!",
|
||||
),
|
||||
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
|
||||
"promo_discount_hint": m3,
|
||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||
"Промокод не найден или истёк",
|
||||
),
|
||||
"promo_remove": MessageLookupByLibrary.simpleMessage("Убрать промокод"),
|
||||
"promo_remove_failed": MessageLookupByLibrary.simpleMessage(
|
||||
"Не удалось убрать промокод. Попробуйте позже",
|
||||
),
|
||||
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
||||
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
|
||||
"publish": MessageLookupByLibrary.simpleMessage("Опубликовать"),
|
||||
|
||||
@@ -4264,6 +4264,56 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Promo code applied!`
|
||||
String get promo_applied_title {
|
||||
return Intl.message(
|
||||
'Promo code applied!',
|
||||
name: 'promo_applied_title',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `The discount will be applied at checkout`
|
||||
String get promo_applied_subtitle {
|
||||
return Intl.message(
|
||||
'The discount will be applied at checkout',
|
||||
name: 'promo_applied_subtitle',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Remove promo code`
|
||||
String get promo_remove {
|
||||
return Intl.message(
|
||||
'Remove promo code',
|
||||
name: 'promo_remove',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Discount {percent}% — you save {amount} ₽`
|
||||
String promo_discount_hint(int percent, String amount) {
|
||||
return Intl.message(
|
||||
'Discount $percent% — you save $amount ₽',
|
||||
name: 'promo_discount_hint',
|
||||
desc: '',
|
||||
args: [percent, amount],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Could not remove the promo code. Try again later`
|
||||
String get promo_remove_failed {
|
||||
return Intl.message(
|
||||
'Could not remove the promo code. Try again later',
|
||||
name: 'promo_remove_failed',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Loading…`
|
||||
String get video_call_screen_loading_title {
|
||||
return Intl.message(
|
||||
|
||||
@@ -502,6 +502,17 @@
|
||||
"promo_code": "Promo code",
|
||||
"enter_promo_code": "Enter promo code",
|
||||
"promo_invalid": "Promo code not found or expired",
|
||||
"promo_applied_title": "Promo code applied!",
|
||||
"promo_applied_subtitle": "The discount will be applied at checkout",
|
||||
"promo_remove": "Remove promo code",
|
||||
"promo_discount_hint": "Discount {percent}% — you save {amount} ₽",
|
||||
"@promo_discount_hint": {
|
||||
"placeholders": {
|
||||
"percent": {"type": "int"},
|
||||
"amount": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"promo_remove_failed": "Could not remove the promo code. Try again later",
|
||||
"video_call_screen_loading_title": "Loading…",
|
||||
"video_call_screen_connecting_title": "Connecting to\nthe consultation",
|
||||
"video_call_screen_connecting_subtitle": "This may take a few seconds",
|
||||
|
||||
@@ -246,6 +246,17 @@
|
||||
"promo_code": "Промокод",
|
||||
"enter_promo_code": "Введите промокод",
|
||||
"promo_invalid": "Промокод не найден или истёк",
|
||||
"promo_applied_title": "Промокод применён!",
|
||||
"promo_applied_subtitle": "Скидка учтётся при оплате",
|
||||
"promo_remove": "Убрать промокод",
|
||||
"promo_discount_hint": "Скидка {percent}% — вы экономите {amount} ₽",
|
||||
"@promo_discount_hint": {
|
||||
"placeholders": {
|
||||
"percent": {"type": "int"},
|
||||
"amount": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"promo_remove_failed": "Не удалось убрать промокод. Попробуйте позже",
|
||||
"video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.",
|
||||
"video_call_screen_loading_title": "Загрузка…",
|
||||
"video_call_screen_connecting_title": "Подключение\nк консультации",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.35+124
|
||||
version: 1.3.39+128
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
@@ -82,6 +82,7 @@ dependencies:
|
||||
rxdart: ^0.27.7
|
||||
gpassword: ^1.0.0
|
||||
webview_flutter_platform_interface: ^2.14.0
|
||||
flutter_inappwebview: ^6.1.5
|
||||
intl_utils: ^2.8.13
|
||||
sentry_dio: ^9.10.0
|
||||
livekit_client: ^2.6.1
|
||||
|
||||
@@ -7,6 +7,32 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
void main() {
|
||||
group('AppRouter', () {
|
||||
group('resolveDeepLinkTarget - blog_post', () {
|
||||
test('ru/blog_post/{id} → /blog-post-screen?id=', () {
|
||||
final target = AppRouter.resolveDeepLinkTarget(
|
||||
Uri.parse('https://eatvio.ru/ru/blog_post/123'),
|
||||
);
|
||||
|
||||
expect(target, '/blog-post-screen?id=123');
|
||||
});
|
||||
|
||||
test('en/blog_post/{id} → /blog-post-screen?id=', () {
|
||||
final target = AppRouter.resolveDeepLinkTarget(
|
||||
Uri.parse('https://eatvio.com/en/blog_post/456'),
|
||||
);
|
||||
|
||||
expect(target, '/blog-post-screen?id=456');
|
||||
});
|
||||
|
||||
test('eatvio.com/blog_post/{id} without locale → /blog-post-screen?id=', () {
|
||||
final target = AppRouter.resolveDeepLinkTarget(
|
||||
Uri.parse('https://eatvio.com/blog_post/789'),
|
||||
);
|
||||
|
||||
expect(target, '/blog-post-screen?id=789');
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveDeepLinkTarget - finance/alfa/return', () {
|
||||
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
|
||||
|
||||
|
||||
@@ -51,8 +51,11 @@ void main() {
|
||||
final blogPostRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/blog-post-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
) as GoRoute;
|
||||
expect(blogPostRoute, isA<GoRoute>());
|
||||
expect(blogPostRoute.path, '/blog-post-screen');
|
||||
expect(blogPostRoute.name, 'blogPostScreen');
|
||||
expect(blogPostRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('aboutPlanScreen builder обрабатывает slug из query параметров', () {
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../../../fixtures/feed_fixtures.dart';
|
||||
|
||||
class MockFeedRepository extends Mock implements FeedRepository {}
|
||||
|
||||
void main() {
|
||||
@@ -29,21 +31,7 @@ void main() {
|
||||
|
||||
test('getBlogPost - успешная загрузка поста', () async {
|
||||
const postId = 1;
|
||||
final mockPost = FeedBlogPostModel(
|
||||
id: postId,
|
||||
title: 'Test Post',
|
||||
state: 'active',
|
||||
image: '',
|
||||
description: 'Test Content',
|
||||
videoUrl: '',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
wasLikedByMe: false,
|
||||
author: const Author(authorName: 'Test Author', authorSlug: 'test-author', authorProfileIcon: '', authorIsMe: false),
|
||||
);
|
||||
final mockPost = FeedFixtures.feedBlogPostModel();
|
||||
|
||||
when(() => mockRepository.getBlogPost(id: postId)).thenAnswer((_) async => mockPost);
|
||||
|
||||
@@ -51,6 +39,8 @@ void main() {
|
||||
|
||||
expect(cubit.state.isLoading, false);
|
||||
expect(cubit.state.blogPostModel, mockPost);
|
||||
expect(cubit.state.blogPostModel?.images, hasLength(2));
|
||||
expect(cubit.state.blogPostModel?.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
verify(() => mockRepository.getBlogPost(id: postId)).called(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,9 +7,8 @@ import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart';
|
||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
||||
import 'package:eatvio_app/features/blog_post/presentation/pages/blog_post_screen.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/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -21,6 +20,7 @@ import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:network_image_mock/network_image_mock.dart';
|
||||
|
||||
import '../../../../fixtures/feed_fixtures.dart';
|
||||
import 'blog_post_screen_smoke_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<BlogPostCubit>(), MockSpec<FeedCubit>(), MockSpec<CommentsCubit>()])
|
||||
@@ -167,21 +167,7 @@ void main() {
|
||||
final blogPostCubit = MockBlogPostCubit();
|
||||
final feedCubit = MockFeedCubit();
|
||||
final commentsCubit = MockCommentsCubit();
|
||||
final blogPostModel = FeedBlogPostModel(
|
||||
id: 1,
|
||||
title: 'Test Post Title',
|
||||
description: 'Test post description',
|
||||
image: 'https://example.com/image.jpg',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2024, 1, 15),
|
||||
updatedAt: DateTime(2024, 1, 15),
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
final blogPostModel = FeedFixtures.feedBlogPostModel();
|
||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||
|
||||
when(blogPostCubit.state).thenReturn(state);
|
||||
@@ -202,7 +188,8 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BlogPostScreen), findsOneWidget);
|
||||
expect(find.text('Test Post Title'), findsOneWidget);
|
||||
expect(find.text('Заголовок'), findsOneWidget);
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,21 +198,7 @@ void main() {
|
||||
final blogPostCubit = MockBlogPostCubit();
|
||||
final feedCubit = MockFeedCubit();
|
||||
final commentsCubit = MockCommentsCubit();
|
||||
final blogPostModel = FeedBlogPostModel(
|
||||
id: 1,
|
||||
title: 'Test Post Title',
|
||||
description: 'Test post description',
|
||||
image: 'https://example.com/image.jpg',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2024, 1, 15),
|
||||
updatedAt: DateTime(2024, 1, 15),
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
final blogPostModel = FeedFixtures.feedBlogPostModel();
|
||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||
|
||||
when(blogPostCubit.state).thenReturn(state);
|
||||
@@ -248,11 +221,104 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byIcon(Icons.favorite_border_rounded));
|
||||
final likeIcon = find.byIcon(Icons.favorite_border_rounded);
|
||||
await tester.ensureVisible(likeIcon);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(likeIcon);
|
||||
await tester.pump();
|
||||
|
||||
verify(blogPostCubit.toggleLike()).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('renders gallery from fixture', (tester) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
final blogPostCubit = MockBlogPostCubit();
|
||||
final feedCubit = MockFeedCubit();
|
||||
final commentsCubit = MockCommentsCubit();
|
||||
final blogPostModel = FeedFixtures.feedBlogPostWithGallery();
|
||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||
|
||||
when(blogPostCubit.state).thenReturn(state);
|
||||
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
registerMocks(blogPostCubit, commentsCubit);
|
||||
|
||||
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id)));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.text('Галерея'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('renders video-only post', (tester) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
final blogPostCubit = MockBlogPostCubit();
|
||||
final feedCubit = MockFeedCubit();
|
||||
final commentsCubit = MockCommentsCubit();
|
||||
final blogPostModel = FeedFixtures.feedBlogPostVideoOnly();
|
||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||
|
||||
when(blogPostCubit.state).thenReturn(state);
|
||||
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
registerMocks(blogPostCubit, commentsCubit);
|
||||
|
||||
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id)));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.text('Видео'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('renders gallery and video post', (tester) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
final blogPostCubit = MockBlogPostCubit();
|
||||
final feedCubit = MockFeedCubit();
|
||||
final commentsCubit = MockCommentsCubit();
|
||||
final blogPostModel = FeedFixtures.feedBlogPostGalleryAndVideo();
|
||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||
|
||||
when(blogPostCubit.state).thenReturn(state);
|
||||
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
registerMocks(blogPostCubit, commentsCubit);
|
||||
|
||||
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id)));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.text('Галерея и видео'), findsWidgets);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,10 +21,18 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
blogPostRepository = FixtureBlogPostRepository();
|
||||
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
||||
fullCreateFixture = FixtureReader.readAsMap('blog_post_create/create_request_full.json');
|
||||
updateFixture = FixtureReader.readAsMap('blog_post_create/update_request.json');
|
||||
deleteFixture = FixtureReader.readAsMap('blog_post_create/delete_request.json');
|
||||
blogPostCreateCubit = BlogPostCreateCubit(
|
||||
blogPostRepository: blogPostRepository,
|
||||
);
|
||||
fullCreateFixture = FixtureReader.readAsMap(
|
||||
'blog_post_create/create_request_full.json',
|
||||
);
|
||||
updateFixture = FixtureReader.readAsMap(
|
||||
'blog_post_create/update_request.json',
|
||||
);
|
||||
deleteFixture = FixtureReader.readAsMap(
|
||||
'blog_post_create/delete_request.json',
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -41,7 +49,9 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('should handle blog post creation with all fields', (WidgetTester tester) async {
|
||||
testWidgets('should handle blog post creation with all fields', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget formWidget = Column(
|
||||
children: [
|
||||
@@ -69,7 +79,6 @@ void main() {
|
||||
title: fullCreateFixture['title'] as String?,
|
||||
description: fullCreateFixture['description_md'] as String?,
|
||||
videoUrl: fullCreateFixture['video_url'] as String?,
|
||||
image: null,
|
||||
);
|
||||
},
|
||||
child: const Text('Опубликовать'),
|
||||
@@ -81,9 +90,18 @@ void main() {
|
||||
await tester.pumpWidget(createTestWidget(formWidget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String);
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String);
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
fullCreateFixture['title'] as String,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
fullCreateFixture['description_md'] as String,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
fullCreateFixture['video_url'] as String,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||
@@ -96,7 +114,9 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should handle blog post creation with image', (WidgetTester tester) async {
|
||||
testWidgets('should handle blog post creation with image', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
final expectedPayload = Map<String, dynamic>.from(fullCreateFixture);
|
||||
expectedPayload['video_url'] = null;
|
||||
@@ -129,7 +149,11 @@ void main() {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
||||
Icon(
|
||||
Icons.add_photo_alternate,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Добавить изображение'),
|
||||
],
|
||||
@@ -145,7 +169,6 @@ void main() {
|
||||
title: fullCreateFixture['title'] as String?,
|
||||
description: fullCreateFixture['description_md'] as String?,
|
||||
videoUrl: null,
|
||||
image: null, // В реальном тесте здесь был бы XFile
|
||||
);
|
||||
},
|
||||
child: const Text('Опубликовать'),
|
||||
@@ -157,15 +180,24 @@ void main() {
|
||||
await tester.pumpWidget(createTestWidget(formWidget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String);
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
fullCreateFixture['title'] as String,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
fullCreateFixture['description_md'] as String,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||
await tester.pump();
|
||||
|
||||
// Assert
|
||||
expect(blogPostRepository.createPayloads.single.toJson(), expectedPayload);
|
||||
expect(
|
||||
blogPostRepository.createPayloads.single.toJson(),
|
||||
expectedPayload,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should handle blog post update', (WidgetTester tester) async {
|
||||
@@ -175,7 +207,12 @@ void main() {
|
||||
state: 'published',
|
||||
image: 'original_image.jpg',
|
||||
description: 'Original Description',
|
||||
author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true),
|
||||
author: const Author(
|
||||
authorSlug: 'test-author',
|
||||
authorName: 'Test Author',
|
||||
authorProfileIcon: 'avatar.jpg',
|
||||
authorIsMe: true,
|
||||
),
|
||||
videoUrl: 'https://youtube.com/watch?v=original',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
@@ -216,8 +253,9 @@ void main() {
|
||||
title: updateFixture['title'] as String?,
|
||||
description: updateFixture['description_md'] as String?,
|
||||
videoUrl: updateFixture['video_url'] as String?,
|
||||
image: null,
|
||||
oldImageUrl: updateFixture['old_image_url'] as String?,
|
||||
existingMediaIds: List<int>.from(
|
||||
updateFixture['existing_media_ids'] as List,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Обновить'),
|
||||
@@ -229,9 +267,18 @@ void main() {
|
||||
await tester.pumpWidget(createTestWidget(formWidget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), updateFixture['title'] as String);
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), updateFixture['description_md'] as String);
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), updateFixture['video_url'] as String);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
updateFixture['title'] as String,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
updateFixture['description_md'] as String,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
updateFixture['video_url'] as String,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||
@@ -241,7 +288,9 @@ void main() {
|
||||
expect(blogPostRepository.updatePayloads.single.toJson(), updateFixture);
|
||||
});
|
||||
|
||||
testWidgets('should handle blog post deletion', (WidgetTester tester) async {
|
||||
testWidgets('should handle blog post deletion', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget deleteWidget = ElevatedButton(
|
||||
key: const Key('deleteBlogPostButton'),
|
||||
@@ -262,18 +311,21 @@ void main() {
|
||||
expect(blogPostRepository.deleteCalls.single, deleteFixture['id']);
|
||||
});
|
||||
|
||||
testWidgets('should handle loading state during blog post creation', (WidgetTester tester) async {
|
||||
testWidgets('should handle loading state during blog post creation', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
blogPostRepository.createDelay = const Duration(milliseconds: 50);
|
||||
|
||||
Widget loadingWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
builder: (context, state) {
|
||||
if (state is BlogPostCreateStateLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return const Text('Not Loading');
|
||||
},
|
||||
);
|
||||
Widget loadingWidget =
|
||||
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return const Text('Not Loading');
|
||||
},
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(loadingWidget));
|
||||
@@ -284,7 +336,6 @@ void main() {
|
||||
title: fullCreateFixture['title'] as String?,
|
||||
videoUrl: null,
|
||||
description: fullCreateFixture['description_md'] as String?,
|
||||
image: null,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(); // Need to pump twice to get the loading state
|
||||
@@ -297,18 +348,21 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('should handle error state during blog post creation', (WidgetTester tester) async {
|
||||
testWidgets('should handle error state during blog post creation', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
blogPostRepository.createError = Exception('Network error');
|
||||
|
||||
Widget errorWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
builder: (context, state) {
|
||||
if (state is BlogPostCreateStateError) {
|
||||
return Center(child: Text('Error: ${state.errorMessage}'));
|
||||
}
|
||||
return const Text('No Error');
|
||||
},
|
||||
);
|
||||
Widget errorWidget =
|
||||
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
builder: (context, state) {
|
||||
if (state.errorMessage != null) {
|
||||
return Center(child: Text('Error: ${state.errorMessage}'));
|
||||
}
|
||||
return const Text('No Error');
|
||||
},
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(errorWidget));
|
||||
@@ -319,7 +373,6 @@ void main() {
|
||||
title: fullCreateFixture['title'] as String?,
|
||||
videoUrl: null,
|
||||
description: fullCreateFixture['description_md'] as String?,
|
||||
image: null,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(); // Need to pump twice to get the error state
|
||||
@@ -328,16 +381,21 @@ void main() {
|
||||
expect(find.textContaining('Error:'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should handle success state after blog post creation', (WidgetTester tester) async {
|
||||
testWidgets('should handle success state after blog post creation', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget successWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
builder: (context, state) {
|
||||
if (state is BlogPostCreateStateLoaded) {
|
||||
return const Center(child: Text('Blog post created successfully!'));
|
||||
}
|
||||
return const Text('Not Loaded');
|
||||
},
|
||||
);
|
||||
Widget successWidget =
|
||||
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
builder: (context, state) {
|
||||
if (state.isSuccess) {
|
||||
return const Center(
|
||||
child: Text('Blog post created successfully!'),
|
||||
);
|
||||
}
|
||||
return const Text('Not Loaded');
|
||||
},
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(successWidget));
|
||||
@@ -348,7 +406,6 @@ void main() {
|
||||
title: fullCreateFixture['title'] as String?,
|
||||
videoUrl: null,
|
||||
description: fullCreateFixture['description_md'] as String?,
|
||||
image: null,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(); // Need to pump twice to get the success state
|
||||
@@ -357,23 +414,35 @@ void main() {
|
||||
expect(find.text('Blog post created successfully!'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should validate form fields before submission', (WidgetTester tester) async {
|
||||
testWidgets('should validate form fields before submission', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget validationWidget = Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок не может быть пустым'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Заголовок',
|
||||
errorText: 'Заголовок не может быть пустым',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание не может быть пустым'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Описание',
|
||||
errorText: 'Описание не может быть пустым',
|
||||
),
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
||||
ElevatedButton(
|
||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||
onPressed: () {},
|
||||
child: const Text('Опубликовать'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -386,7 +455,9 @@ void main() {
|
||||
expect(find.text('Описание не может быть пустым'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should handle image picker interaction', (WidgetTester tester) async {
|
||||
testWidgets('should handle image picker interaction', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
bool imagePickerCalled = false;
|
||||
Widget imagePickerWidget = GestureDetector(
|
||||
@@ -425,16 +496,25 @@ void main() {
|
||||
expect(imagePickerCalled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('should handle video URL validation', (WidgetTester tester) async {
|
||||
testWidgets('should handle video URL validation', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget videoValidationWidget = Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||
decoration: const InputDecoration(labelText: 'Ссылка на видео', errorText: 'Введите корректную ссылку на видео'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Ссылка на видео',
|
||||
errorText: 'Введите корректную ссылку на видео',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
||||
ElevatedButton(
|
||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||
onPressed: () {},
|
||||
child: const Text('Опубликовать'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -446,7 +526,9 @@ void main() {
|
||||
expect(find.text('Введите корректную ссылку на видео'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should handle form submission with empty fields', (WidgetTester tester) async {
|
||||
testWidgets('should handle form submission with empty fields', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget emptyFormWidget = Column(
|
||||
children: [
|
||||
@@ -465,7 +547,11 @@ void main() {
|
||||
ElevatedButton(
|
||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||
onPressed: () async {
|
||||
await blogPostCreateCubit.createBlogPost(title: '', videoUrl: '', description: '', image: null);
|
||||
await blogPostCreateCubit.createBlogPost(
|
||||
title: '',
|
||||
videoUrl: '',
|
||||
description: '',
|
||||
);
|
||||
},
|
||||
child: const Text('Опубликовать'),
|
||||
),
|
||||
@@ -479,14 +565,14 @@ void main() {
|
||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||
await tester.pump();
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
blogPostRepository.createPayloads.single.toJson(),
|
||||
{'title': '', 'description_md': '', 'video_url': '', 'image_name': null},
|
||||
);
|
||||
// Assert — invalid empty payload must not reach the repository
|
||||
expect(blogPostRepository.createPayloads, isEmpty);
|
||||
expect(blogPostCreateCubit.state.hasValidationErrors, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('should handle blog post creation with long content', (WidgetTester tester) async {
|
||||
testWidgets('should handle blog post creation with long content', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
const longContent =
|
||||
'Это очень длинное описание блог поста, которое содержит много текста и должно быть правильно обработано системой. '
|
||||
@@ -510,7 +596,11 @@ void main() {
|
||||
ElevatedButton(
|
||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||
onPressed: () async {
|
||||
await blogPostCreateCubit.createBlogPost(title: 'Test Title', videoUrl: null, description: longContent, image: null);
|
||||
await blogPostCreateCubit.createBlogPost(
|
||||
title: 'Test Title',
|
||||
videoUrl: null,
|
||||
description: longContent,
|
||||
);
|
||||
},
|
||||
child: const Text('Опубликовать'),
|
||||
),
|
||||
@@ -521,23 +611,26 @@ void main() {
|
||||
await tester.pumpWidget(createTestWidget(longContentWidget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'Test Title');
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), longContent);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
'Test Title',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
longContent,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||
await tester.pump();
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
blogPostRepository.createPayloads.single.toJson(),
|
||||
{
|
||||
'title': 'Test Title',
|
||||
'description_md': longContent,
|
||||
'video_url': null,
|
||||
'image_name': null,
|
||||
},
|
||||
);
|
||||
expect(blogPostRepository.createPayloads.single.toJson(), {
|
||||
'title': 'Test Title',
|
||||
'description_md': longContent,
|
||||
'video_url': null,
|
||||
'image_name': null,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
blogPostRepository = FixtureBlogPostRepository();
|
||||
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
||||
blogPostCreateCubit = BlogPostCreateCubit(
|
||||
blogPostRepository: blogPostRepository,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -31,7 +33,9 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('should create BlogPostCreateCubit successfully', (WidgetTester tester) async {
|
||||
testWidgets('should create BlogPostCreateCubit successfully', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange & Act
|
||||
final cubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
||||
|
||||
@@ -41,13 +45,18 @@ void main() {
|
||||
cubit.close();
|
||||
});
|
||||
|
||||
testWidgets('should display basic blog post form fields', (WidgetTester tester) async {
|
||||
testWidgets('should display basic blog post form fields', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget formWidget = Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Заголовок',
|
||||
hintText: 'Введите заголовок поста',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
@@ -62,7 +71,11 @@ void main() {
|
||||
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
||||
ElevatedButton(
|
||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||
onPressed: () {},
|
||||
child: const Text('Опубликовать'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -71,17 +84,34 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Assert
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should update title when user types in title field', (WidgetTester tester) async {
|
||||
testWidgets('should update title when user types in title field', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget formWidget = TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Заголовок',
|
||||
hintText: 'Введите заголовок поста',
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(formWidget));
|
||||
@@ -90,36 +120,47 @@ void main() {
|
||||
const testTitle = 'Тестовый заголовок поста';
|
||||
|
||||
// Act
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), testTitle);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
testTitle,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Assert
|
||||
expect(find.text(testTitle), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should update description when user types in description field', (WidgetTester tester) async {
|
||||
// Arrange
|
||||
Widget formWidget = TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
decoration: const InputDecoration(labelText: 'Описание'),
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
);
|
||||
testWidgets(
|
||||
'should update description when user types in description field',
|
||||
(WidgetTester tester) async {
|
||||
// Arrange
|
||||
Widget formWidget = TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
decoration: const InputDecoration(labelText: 'Описание'),
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(formWidget));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(createTestWidget(formWidget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
const testDescription = 'Это тестовое описание блог поста';
|
||||
const testDescription = 'Это тестовое описание блог поста';
|
||||
|
||||
// Act
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), testDescription);
|
||||
await tester.pump();
|
||||
// Act
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
testDescription,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Assert
|
||||
expect(find.text(testDescription), findsOneWidget);
|
||||
});
|
||||
// Assert
|
||||
expect(find.text(testDescription), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should update video URL when user types in video field', (WidgetTester tester) async {
|
||||
testWidgets('should update video URL when user types in video field', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget formWidget = TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||
@@ -132,16 +173,27 @@ void main() {
|
||||
const testVideoUrl = 'https://youtube.com/watch?v=example';
|
||||
|
||||
// Act
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), testVideoUrl);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
testVideoUrl,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Assert
|
||||
expect(find.text(testVideoUrl), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display form labels correctly', (WidgetTester tester) async {
|
||||
testWidgets('should display form labels correctly', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget formWidget = const Column(children: [Text('Заголовок'), Text('Описание'), Text('Ссылка на видео')]);
|
||||
Widget formWidget = const Column(
|
||||
children: [
|
||||
Text('Заголовок'),
|
||||
Text('Описание'),
|
||||
Text('Ссылка на видео'),
|
||||
],
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(formWidget));
|
||||
@@ -153,11 +205,16 @@ void main() {
|
||||
expect(find.text('Ссылка на видео'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display placeholder text in title field', (WidgetTester tester) async {
|
||||
testWidgets('should display placeholder text in title field', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget formWidget = TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Заголовок',
|
||||
hintText: 'Введите заголовок поста',
|
||||
),
|
||||
);
|
||||
|
||||
// Act
|
||||
@@ -170,14 +227,21 @@ void main() {
|
||||
|
||||
testWidgets('should display create button', (WidgetTester tester) async {
|
||||
// Arrange
|
||||
Widget formWidget = ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать'));
|
||||
Widget formWidget = ElevatedButton(
|
||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||
onPressed: () {},
|
||||
child: const Text('Опубликовать'),
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(formWidget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Assert
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Опубликовать'), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -217,19 +281,26 @@ void main() {
|
||||
|
||||
testWidgets('should display error message', (WidgetTester tester) async {
|
||||
// Arrange
|
||||
Widget errorWidget = const Center(child: Text('Произошла ошибка при создании блог поста'));
|
||||
Widget errorWidget = const Center(
|
||||
child: Text('Произошла ошибка при создании блог поста'),
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(errorWidget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Assert
|
||||
expect(find.text('Произошла ошибка при создании блог поста'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Произошла ошибка при создании блог поста'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should display success message', (WidgetTester tester) async {
|
||||
// Arrange
|
||||
Widget successWidget = const Center(child: Text('Блог пост успешно создан!'));
|
||||
Widget successWidget = const Center(
|
||||
child: Text('Блог пост успешно создан!'),
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(successWidget));
|
||||
@@ -245,12 +316,18 @@ void main() {
|
||||
children: [
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок обязателен'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Заголовок',
|
||||
errorText: 'Заголовок обязателен',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание обязательно'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Описание',
|
||||
errorText: 'Описание обязательно',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -264,13 +341,18 @@ void main() {
|
||||
expect(find.text('Описание обязательно'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should display form with all required fields', (WidgetTester tester) async {
|
||||
testWidgets('should display form with all required fields', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget completeForm = Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Заголовок',
|
||||
hintText: 'Введите заголовок поста',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
@@ -285,7 +367,11 @@ void main() {
|
||||
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
||||
ElevatedButton(
|
||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||
onPressed: () {},
|
||||
child: const Text('Опубликовать'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -294,13 +380,27 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Assert
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should display image upload widget', (WidgetTester tester) async {
|
||||
testWidgets('should display image upload widget', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget imageWidget = GestureDetector(
|
||||
key: AppKeys.imageWidget,
|
||||
@@ -373,68 +473,101 @@ void main() {
|
||||
expect(imageUploadTapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('should display blog post creation form with image and video options', (WidgetTester tester) async {
|
||||
// Arrange
|
||||
Widget completeForm = Column(
|
||||
children: [
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
decoration: const InputDecoration(hintText: 'Введите заголовок поста'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(key: AppKeys.blogPostCreateScreenContentTextField, decoration: const InputDecoration(), maxLines: 6, minLines: 4),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Изображение поста'),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
key: AppKeys.imageWidget,
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
testWidgets(
|
||||
'should display blog post creation form with image and video options',
|
||||
(WidgetTester tester) async {
|
||||
// Arrange
|
||||
Widget completeForm = Column(
|
||||
children: [
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Введите заголовок поста',
|
||||
),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
||||
SizedBox(height: 8),
|
||||
Text('Добавить изображение'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
decoration: const InputDecoration(),
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Изображение поста'),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
key: AppKeys.imageWidget,
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_photo_alternate,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Добавить изображение'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ссылка на видео'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration()),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
||||
],
|
||||
);
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ссылка на видео'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||
onPressed: () {},
|
||||
child: const Text('Опубликовать'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(completeForm));
|
||||
await tester.pumpAndSettle();
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(completeForm));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Assert
|
||||
expect(find.text('Заголовок'), findsOneWidget);
|
||||
expect(find.text('Описание'), findsOneWidget);
|
||||
expect(find.text('Изображение поста'), findsOneWidget);
|
||||
expect(find.text('Ссылка на видео'), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.imageWidget), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget);
|
||||
});
|
||||
// Assert
|
||||
expect(find.text('Заголовок'), findsOneWidget);
|
||||
expect(find.text('Описание'), findsOneWidget);
|
||||
expect(find.text('Изображение поста'), findsOneWidget);
|
||||
expect(find.text('Ссылка на видео'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(AppKeys.imageWidget), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
@@ -14,6 +16,8 @@ void main() {
|
||||
setUp(() {
|
||||
mockRepository = MockBlogPostRepository();
|
||||
cubit = BlogPostCreateCubit(blogPostRepository: mockRepository);
|
||||
registerFallbackValue(<XFile>[]);
|
||||
registerFallbackValue(<int>[]);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -21,63 +25,246 @@ void main() {
|
||||
});
|
||||
|
||||
test('начальное состояние - BlogPostCreateState', () {
|
||||
expect(cubit.state, isA<BlogPostCreateState>());
|
||||
expect(cubit.state, const BlogPostCreateState());
|
||||
});
|
||||
|
||||
test('createBlogPost - успешное создание', () async {
|
||||
when(() => mockRepository.createBlogPost(any(), any(), any(), any()))
|
||||
.thenAnswer((_) async => {});
|
||||
test('validateForm - пустая форма заполняет formError', () {
|
||||
final isValid = cubit.validateForm(
|
||||
title: '',
|
||||
description: '',
|
||||
videoUrl: '',
|
||||
imageCount: 0,
|
||||
);
|
||||
|
||||
final result = await cubit.createBlogPost();
|
||||
expect(isValid, false);
|
||||
expect(cubit.state.formError, BlogPostFormValidator.contentRequired);
|
||||
verifyNever(
|
||||
() => mockRepository.createBlogPost(
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
images: any(named: 'images'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('validateForm - невалидное видео заполняет videoUrlError', () {
|
||||
final isValid = cubit.validateForm(
|
||||
title: 'Valid title',
|
||||
description: '',
|
||||
videoUrl: 'https://youtube.com/watch?v=abc',
|
||||
imageCount: 0,
|
||||
);
|
||||
|
||||
expect(isValid, false);
|
||||
expect(
|
||||
cubit.state.videoUrlError,
|
||||
BlogPostFormValidator.videoUrlUnsupportedHost,
|
||||
);
|
||||
expect(cubit.state.formError, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'createBlogPost - не вызывает репозиторий при ошибке валидации',
|
||||
() async {
|
||||
final result = await cubit.createBlogPost();
|
||||
|
||||
expect(result, false);
|
||||
expect(cubit.state.hasValidationErrors, true);
|
||||
verifyNever(
|
||||
() => mockRepository.createBlogPost(
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
images: any(named: 'images'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('createBlogPost - успешное создание', () async {
|
||||
when(
|
||||
() => mockRepository.createBlogPost(
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
images: any(named: 'images'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
final result = await cubit.createBlogPost(
|
||||
title: 'Test Title',
|
||||
description: 'Test Description',
|
||||
);
|
||||
|
||||
expect(result, true);
|
||||
expect(cubit.state, isA<BlogPostCreateStateLoaded>());
|
||||
verify(() => mockRepository.createBlogPost(null, null, null, null)).called(1);
|
||||
expect(cubit.state.isSuccess, true);
|
||||
verify(
|
||||
() => mockRepository.createBlogPost(
|
||||
title: 'Test Title',
|
||||
videoUrl: null,
|
||||
description: 'Test Description',
|
||||
images: const [],
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обработка ошибки', () async {
|
||||
when(() => mockRepository.createBlogPost(any(), any(), any(), any()))
|
||||
.thenThrow(Exception('Error'));
|
||||
when(
|
||||
() => mockRepository.createBlogPost(
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
images: any(named: 'images'),
|
||||
),
|
||||
).thenThrow(Exception('Error'));
|
||||
|
||||
final result = await cubit.createBlogPost();
|
||||
final result = await cubit.createBlogPost(
|
||||
title: 'Test Title',
|
||||
description: 'Test Description',
|
||||
);
|
||||
|
||||
expect(result, false);
|
||||
expect(cubit.state, isA<BlogPostCreateStateError>());
|
||||
final errorState = cubit.state as BlogPostCreateStateError;
|
||||
expect(errorState.errorMessage, contains('Error'));
|
||||
expect(cubit.state.errorMessage, contains('Error'));
|
||||
expect(cubit.state.isSuccess, false);
|
||||
});
|
||||
|
||||
test(
|
||||
'updateBlogPost - не вызывает репозиторий при ошибке валидации',
|
||||
() async {
|
||||
final result = await cubit.updateBlogPost(id: 1);
|
||||
|
||||
expect(result, false);
|
||||
expect(cubit.state.hasValidationErrors, true);
|
||||
expect(cubit.state.formError, BlogPostFormValidator.contentRequired);
|
||||
verifyNever(
|
||||
() => mockRepository.updateBlogPost(
|
||||
id: any(named: 'id'),
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
existingMediaIds: any(named: 'existingMediaIds'),
|
||||
newImages: any(named: 'newImages'),
|
||||
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('updateBlogPost - невалидное видео не уходит в репозиторий', () async {
|
||||
final result = await cubit.updateBlogPost(
|
||||
id: 1,
|
||||
title: 'Valid title',
|
||||
videoUrl: 'https://youtube.com/watch?v=abc',
|
||||
existingMediaIds: const [10],
|
||||
);
|
||||
|
||||
expect(result, false);
|
||||
expect(
|
||||
cubit.state.videoUrlError,
|
||||
BlogPostFormValidator.videoUrlUnsupportedHost,
|
||||
);
|
||||
verifyNever(
|
||||
() => mockRepository.updateBlogPost(
|
||||
id: any(named: 'id'),
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
existingMediaIds: any(named: 'existingMediaIds'),
|
||||
newImages: any(named: 'newImages'),
|
||||
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'updateBlogPost - короткий заголовок не уходит в репозиторий',
|
||||
() async {
|
||||
final result = await cubit.updateBlogPost(
|
||||
id: 1,
|
||||
title: 'ab',
|
||||
existingMediaIds: const [10],
|
||||
);
|
||||
|
||||
expect(result, false);
|
||||
expect(cubit.state.titleError, BlogPostFormValidator.titleTooShort);
|
||||
verifyNever(
|
||||
() => mockRepository.updateBlogPost(
|
||||
id: any(named: 'id'),
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
existingMediaIds: any(named: 'existingMediaIds'),
|
||||
newImages: any(named: 'newImages'),
|
||||
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('updateBlogPost - успешное обновление', () async {
|
||||
const id = 1;
|
||||
const title = 'Updated Title';
|
||||
|
||||
when(() => mockRepository.updateBlogPost(any(), any(), any(), any(), any(), any()))
|
||||
.thenAnswer((_) async => {});
|
||||
when(
|
||||
() => mockRepository.updateBlogPost(
|
||||
id: any(named: 'id'),
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
existingMediaIds: any(named: 'existingMediaIds'),
|
||||
newImages: any(named: 'newImages'),
|
||||
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
final result = await cubit.updateBlogPost(id: id, title: title);
|
||||
final result = await cubit.updateBlogPost(
|
||||
id: id,
|
||||
title: title,
|
||||
existingMediaIds: [2, 1],
|
||||
mediaIdsToDelete: [9],
|
||||
);
|
||||
|
||||
expect(result, true);
|
||||
expect(cubit.state, isA<BlogPostCreateStateLoaded>());
|
||||
verify(() => mockRepository.updateBlogPost(id, title, null, null, null, null)).called(1);
|
||||
expect(cubit.state.isSuccess, true);
|
||||
verify(
|
||||
() => mockRepository.updateBlogPost(
|
||||
id: id,
|
||||
title: title,
|
||||
videoUrl: '',
|
||||
description: '',
|
||||
existingMediaIds: [2, 1],
|
||||
newImages: const [],
|
||||
mediaIdsToDelete: [9],
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('updateBlogPost - обработка ошибки', () async {
|
||||
const id = 1;
|
||||
|
||||
when(() => mockRepository.updateBlogPost(any(), any(), any(), any(), any(), any()))
|
||||
.thenThrow(Exception('Error'));
|
||||
when(
|
||||
() => mockRepository.updateBlogPost(
|
||||
id: any(named: 'id'),
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
existingMediaIds: any(named: 'existingMediaIds'),
|
||||
newImages: any(named: 'newImages'),
|
||||
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||
),
|
||||
).thenThrow(Exception('Error'));
|
||||
|
||||
final result = await cubit.updateBlogPost(id: id);
|
||||
final result = await cubit.updateBlogPost(id: id, title: 'Valid title');
|
||||
|
||||
expect(result, false);
|
||||
expect(cubit.state, isA<BlogPostCreateStateError>());
|
||||
expect(cubit.state.errorMessage, contains('Error'));
|
||||
});
|
||||
|
||||
test('deleteBlogPost - успешное удаление', () async {
|
||||
const id = 1;
|
||||
|
||||
when(() => mockRepository.deleteBlogPost(id))
|
||||
.thenAnswer((_) async => {});
|
||||
when(() => mockRepository.deleteBlogPost(id)).thenAnswer((_) async => {});
|
||||
|
||||
final result = await cubit.deleteBlogPost(id);
|
||||
|
||||
@@ -88,13 +275,27 @@ void main() {
|
||||
test('deleteBlogPost - обработка ошибки', () async {
|
||||
const id = 1;
|
||||
|
||||
when(() => mockRepository.deleteBlogPost(id))
|
||||
.thenThrow(Exception('Error'));
|
||||
when(
|
||||
() => mockRepository.deleteBlogPost(id),
|
||||
).thenThrow(Exception('Error'));
|
||||
|
||||
final result = await cubit.deleteBlogPost(id);
|
||||
|
||||
expect(result, false);
|
||||
});
|
||||
|
||||
test('clearTitleError очищает titleError и formError', () {
|
||||
cubit.validateForm(
|
||||
title: '',
|
||||
description: '',
|
||||
videoUrl: '',
|
||||
imageCount: 0,
|
||||
);
|
||||
expect(cubit.state.formError, isNotNull);
|
||||
|
||||
cubit.clearTitleError();
|
||||
|
||||
expect(cubit.state.formError, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,16 @@ void main() {
|
||||
|
||||
setUp(() {
|
||||
blogPostRepository = FixtureBlogPostRepository();
|
||||
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
||||
blogPostCreateCubit = BlogPostCreateCubit(
|
||||
blogPostRepository: blogPostRepository,
|
||||
);
|
||||
feedProbe = FeedReloadProbe();
|
||||
fullCreateFixture = FixtureReader.readAsMap('blog_post_create/create_request_full.json');
|
||||
updateFixture = FixtureReader.readAsMap('blog_post_create/update_request.json');
|
||||
fullCreateFixture = FixtureReader.readAsMap(
|
||||
'blog_post_create/create_request_full.json',
|
||||
);
|
||||
updateFixture = FixtureReader.readAsMap(
|
||||
'blog_post_create/update_request.json',
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
@@ -41,11 +47,14 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('should complete full blog post creation flow', (WidgetTester tester) async {
|
||||
testWidgets('should complete full blog post creation flow', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget fullFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
Widget
|
||||
fullFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
listener: (context, state) {
|
||||
if (state is BlogPostCreateStateLoaded) {
|
||||
if (state.isSuccess) {
|
||||
feedProbe.loadFeedBlogPostList();
|
||||
}
|
||||
},
|
||||
@@ -82,7 +91,11 @@ void main() {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
||||
Icon(
|
||||
Icons.add_photo_alternate,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Добавить изображение'),
|
||||
],
|
||||
@@ -98,7 +111,6 @@ void main() {
|
||||
title: fullCreateFixture['title'] as String?,
|
||||
videoUrl: fullCreateFixture['video_url'] as String?,
|
||||
description: fullCreateFixture['description_md'] as String?,
|
||||
image: null,
|
||||
);
|
||||
},
|
||||
child: const Text('Опубликовать'),
|
||||
@@ -111,27 +123,46 @@ void main() {
|
||||
await tester.pumpWidget(createTestWidget(fullFlowWidget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String);
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String);
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
fullCreateFixture['title'] as String,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
fullCreateFixture['description_md'] as String,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
fullCreateFixture['video_url'] as String,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||
await tester.pump();
|
||||
|
||||
// Assert
|
||||
expect(blogPostRepository.createPayloads.single.toJson(), fullCreateFixture);
|
||||
expect(
|
||||
blogPostRepository.createPayloads.single.toJson(),
|
||||
fullCreateFixture,
|
||||
);
|
||||
expect(feedProbe.loadRequests, 1);
|
||||
});
|
||||
|
||||
testWidgets('should handle blog post update flow', (WidgetTester tester) async {
|
||||
testWidgets('should handle blog post update flow', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
final existingBlogPost = FeedBlogPostModel(
|
||||
id: 1,
|
||||
state: 'published',
|
||||
image: 'original_image.jpg',
|
||||
description: 'Original Description',
|
||||
author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true),
|
||||
author: const Author(
|
||||
authorSlug: 'test-author',
|
||||
authorName: 'Test Author',
|
||||
authorProfileIcon: 'avatar.jpg',
|
||||
authorIsMe: true,
|
||||
),
|
||||
videoUrl: 'https://youtube.com/watch?v=original',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
@@ -142,9 +173,10 @@ void main() {
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
Widget updateFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
Widget
|
||||
updateFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
listener: (context, state) {
|
||||
if (state is BlogPostCreateStateLoaded) {
|
||||
if (state.isSuccess) {
|
||||
feedProbe.loadFeedBlogPostList();
|
||||
}
|
||||
},
|
||||
@@ -161,13 +193,17 @@ void main() {
|
||||
decoration: const InputDecoration(labelText: 'Описание'),
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
controller: TextEditingController(text: existingBlogPost.description),
|
||||
controller: TextEditingController(
|
||||
text: existingBlogPost.description,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
||||
controller: TextEditingController(text: existingBlogPost.videoUrl),
|
||||
controller: TextEditingController(
|
||||
text: existingBlogPost.videoUrl,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
@@ -178,8 +214,9 @@ void main() {
|
||||
title: updateFixture['title'] as String?,
|
||||
videoUrl: updateFixture['video_url'] as String?,
|
||||
description: updateFixture['description_md'] as String?,
|
||||
image: null,
|
||||
oldImageUrl: updateFixture['old_image_url'] as String?,
|
||||
existingMediaIds: List<int>.from(
|
||||
updateFixture['existing_media_ids'] as List,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Обновить'),
|
||||
@@ -192,9 +229,18 @@ void main() {
|
||||
await tester.pumpWidget(createTestWidget(updateFlowWidget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), updateFixture['title'] as String);
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), updateFixture['description_md'] as String);
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), updateFixture['video_url'] as String);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
updateFixture['title'] as String,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
updateFixture['description_md'] as String,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
updateFixture['video_url'] as String,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||
@@ -205,7 +251,9 @@ void main() {
|
||||
expect(feedProbe.loadRequests, 1);
|
||||
});
|
||||
|
||||
testWidgets('should handle blog post deletion flow', (WidgetTester tester) async {
|
||||
testWidgets('should handle blog post deletion flow', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget deleteFlowWidget = Column(
|
||||
children: [
|
||||
@@ -230,35 +278,44 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
// Assert
|
||||
final deleteFixture = FixtureReader.readAsMap('blog_post_create/delete_request.json');
|
||||
final deleteFixture = FixtureReader.readAsMap(
|
||||
'blog_post_create/delete_request.json',
|
||||
);
|
||||
expect(blogPostRepository.deleteCalls.single, deleteFixture['id']);
|
||||
expect(feedProbe.loadRequests, 1);
|
||||
});
|
||||
|
||||
testWidgets('should handle error flow during blog post creation', (WidgetTester tester) async {
|
||||
testWidgets('should handle error flow during blog post creation', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
blogPostRepository.createError = Exception('Network error');
|
||||
|
||||
Widget errorFlowWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
builder: (context, state) {
|
||||
if (state is BlogPostCreateStateError) {
|
||||
return Column(
|
||||
children: [
|
||||
Text('Error: ${state.errorMessage}'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
key: const Key('retryButton'),
|
||||
onPressed: () async {
|
||||
await blogPostCreateCubit.createBlogPost(title: 'Retry Title', videoUrl: null, description: 'Retry Description', image: null);
|
||||
},
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const Text('No Error');
|
||||
},
|
||||
);
|
||||
Widget errorFlowWidget =
|
||||
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
builder: (context, state) {
|
||||
if (state.errorMessage != null) {
|
||||
return Column(
|
||||
children: [
|
||||
Text('Error: ${state.errorMessage}'),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
key: const Key('retryButton'),
|
||||
onPressed: () async {
|
||||
await blogPostCreateCubit.createBlogPost(
|
||||
title: 'Retry Title',
|
||||
videoUrl: null,
|
||||
description: 'Retry Description',
|
||||
);
|
||||
},
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const Text('No Error');
|
||||
},
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(errorFlowWidget));
|
||||
@@ -269,7 +326,6 @@ void main() {
|
||||
title: fullCreateFixture['title'] as String?,
|
||||
videoUrl: null,
|
||||
description: fullCreateFixture['description_md'] as String?,
|
||||
image: null,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
@@ -278,18 +334,27 @@ void main() {
|
||||
expect(find.byKey(const Key('retryButton')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should handle loading state during blog post creation', (WidgetTester tester) async {
|
||||
testWidgets('should handle loading state during blog post creation', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
blogPostRepository.createDelay = const Duration(milliseconds: 50);
|
||||
|
||||
Widget loadingFlowWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
builder: (context, state) {
|
||||
if (state is BlogPostCreateStateLoading) {
|
||||
return const Column(children: [CircularProgressIndicator(), SizedBox(height: 16), Text('Создание блог поста...')]);
|
||||
}
|
||||
return const Text('Not Loading');
|
||||
},
|
||||
);
|
||||
Widget loadingFlowWidget =
|
||||
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Column(
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Создание блог поста...'),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const Text('Not Loading');
|
||||
},
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(loadingFlowWidget));
|
||||
@@ -300,7 +365,6 @@ void main() {
|
||||
title: fullCreateFixture['title'] as String?,
|
||||
videoUrl: null,
|
||||
description: fullCreateFixture['description_md'] as String?,
|
||||
image: null,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(); // Need to pump twice to get the loading state
|
||||
@@ -314,29 +378,32 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('should handle success state after blog post creation', (WidgetTester tester) async {
|
||||
testWidgets('should handle success state after blog post creation', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget successFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
listener: (context, state) {
|
||||
if (state is BlogPostCreateStateLoaded) {
|
||||
feedProbe.loadFeedBlogPostList();
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
builder: (context, state) {
|
||||
if (state is BlogPostCreateStateLoaded) {
|
||||
return const Column(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green, size: 48),
|
||||
SizedBox(height: 16),
|
||||
Text('Блог пост успешно создан!'),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const Text('Not Loaded');
|
||||
},
|
||||
),
|
||||
);
|
||||
Widget successFlowWidget =
|
||||
BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
listener: (context, state) {
|
||||
if (state.isSuccess) {
|
||||
feedProbe.loadFeedBlogPostList();
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
builder: (context, state) {
|
||||
if (state.isSuccess) {
|
||||
return const Column(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green, size: 48),
|
||||
SizedBox(height: 16),
|
||||
Text('Блог пост успешно создан!'),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const Text('Not Loaded');
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(successFlowWidget));
|
||||
@@ -347,7 +414,6 @@ void main() {
|
||||
title: fullCreateFixture['title'] as String?,
|
||||
videoUrl: null,
|
||||
description: fullCreateFixture['description_md'] as String?,
|
||||
image: null,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
@@ -357,28 +423,43 @@ void main() {
|
||||
expect(feedProbe.loadRequests, 1);
|
||||
});
|
||||
|
||||
testWidgets('should handle form validation flow', (WidgetTester tester) async {
|
||||
testWidgets('should handle form validation flow', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget validationFlowWidget = Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок обязателен'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Заголовок',
|
||||
errorText: 'Заголовок обязателен',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание обязательно'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Описание',
|
||||
errorText: 'Описание обязательно',
|
||||
),
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||
decoration: const InputDecoration(labelText: 'Ссылка на видео', errorText: 'Введите корректную ссылку'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Ссылка на видео',
|
||||
errorText: 'Введите корректную ссылку',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
||||
ElevatedButton(
|
||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||
onPressed: () {},
|
||||
child: const Text('Опубликовать'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -415,7 +496,11 @@ void main() {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
||||
Icon(
|
||||
Icons.add_photo_alternate,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Добавить изображение'),
|
||||
],
|
||||
@@ -431,7 +516,6 @@ void main() {
|
||||
title: 'Test Title',
|
||||
videoUrl: null,
|
||||
description: 'Test Description',
|
||||
image: null, // В реальном тесте здесь был бы XFile
|
||||
);
|
||||
},
|
||||
child: const Text('Опубликовать'),
|
||||
@@ -450,90 +534,122 @@ void main() {
|
||||
expect(imageUploadInitiated, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('should handle complete blog post creation with all features', (WidgetTester tester) async {
|
||||
testWidgets('should handle complete blog post creation with all features', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
Widget completeFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
listener: (context, state) {
|
||||
if (state is BlogPostCreateStateLoaded) {
|
||||
feedProbe.loadFeedBlogPostList();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
decoration: const InputDecoration(hintText: 'Введите заголовок поста'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(key: AppKeys.blogPostCreateScreenContentTextField, decoration: const InputDecoration(), maxLines: 6, minLines: 4),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Изображение поста'),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
key: AppKeys.imageWidget,
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
||||
SizedBox(height: 8),
|
||||
Text('Добавить изображение'),
|
||||
],
|
||||
Widget completeFlowWidget =
|
||||
BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
listener: (context, state) {
|
||||
if (state.isSuccess) {
|
||||
feedProbe.loadFeedBlogPostList();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Введите заголовок поста',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
decoration: const InputDecoration(),
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Изображение поста'),
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
key: AppKeys.imageWidget,
|
||||
onTap: () {},
|
||||
child: Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_photo_alternate,
|
||||
size: 48,
|
||||
color: Colors.grey,
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text('Добавить изображение'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ссылка на видео'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||
decoration: const InputDecoration(),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||
onPressed: () async {
|
||||
await blogPostCreateCubit.createBlogPost(
|
||||
title: fullCreateFixture['title'] as String?,
|
||||
videoUrl: fullCreateFixture['video_url'] as String?,
|
||||
description:
|
||||
fullCreateFixture['description_md'] as String?,
|
||||
);
|
||||
},
|
||||
child: const Text('Опубликовать'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ссылка на видео'),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration()),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton(
|
||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||
onPressed: () async {
|
||||
await blogPostCreateCubit.createBlogPost(
|
||||
title: fullCreateFixture['title'] as String?,
|
||||
videoUrl: fullCreateFixture['video_url'] as String?,
|
||||
description: fullCreateFixture['description_md'] as String?,
|
||||
image: null,
|
||||
);
|
||||
},
|
||||
child: const Text('Опубликовать'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(completeFlowWidget));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String);
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String);
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
fullCreateFixture['title'] as String,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
fullCreateFixture['description_md'] as String,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
fullCreateFixture['video_url'] as String,
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Scroll to make sure the button is visible
|
||||
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -200));
|
||||
await tester.drag(
|
||||
find.byType(SingleChildScrollView),
|
||||
const Offset(0, -200),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||
await tester.pump();
|
||||
|
||||
// Assert
|
||||
expect(blogPostRepository.createPayloads.single.toJson(), fullCreateFixture);
|
||||
expect(
|
||||
blogPostRepository.createPayloads.single.toJson(),
|
||||
fullCreateFixture,
|
||||
);
|
||||
expect(feedProbe.loadRequests, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/features/blog_post_create/presentation/pages/blog_pos
|
||||
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
@@ -19,14 +20,15 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:network_image_mock/network_image_mock.dart';
|
||||
|
||||
import 'blog_post_create_screen_test.mocks.dart';
|
||||
class MockBlogPostRepository extends Mock implements BlogPostRepository {}
|
||||
|
||||
class MockFeedCubit extends Mock implements FeedCubit {}
|
||||
|
||||
@GenerateMocks([BlogPostRepository, BlogPostCreateCubit, FeedCubit])
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -37,13 +39,22 @@ void main() {
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
AppConfigRegistration.registerAppConfig(buildType: AppBuildType.tests);
|
||||
registerFallbackValue(<XFile>[]);
|
||||
registerFallbackValue(<int>[]);
|
||||
mockBlogPostRepository = MockBlogPostRepository();
|
||||
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: mockBlogPostRepository);
|
||||
blogPostCreateCubit = BlogPostCreateCubit(
|
||||
blogPostRepository: mockBlogPostRepository,
|
||||
);
|
||||
mockFeedCubit = MockFeedCubit();
|
||||
when(mockFeedCubit.state).thenReturn(const FeedState.loading());
|
||||
when(mockFeedCubit.stream).thenAnswer((_) => Stream.value(const FeedState.loading()));
|
||||
when(() => mockFeedCubit.state).thenReturn(const FeedState.loading());
|
||||
when(
|
||||
() => mockFeedCubit.stream,
|
||||
).thenAnswer((_) => Stream.value(const FeedState.loading()));
|
||||
when(() => mockFeedCubit.loadFeedBlogPostList()).thenAnswer((_) async {});
|
||||
sl.registerLazySingleton<FeedCubit>(() => mockFeedCubit);
|
||||
sl.registerLazySingleton<BlogPostCreateCubit>(() => blogPostCreateCubit);
|
||||
});
|
||||
@@ -52,19 +63,25 @@ void main() {
|
||||
blogPostCreateCubit.close();
|
||||
});
|
||||
|
||||
Widget createTestWidget({FeedBlogPostModel? feedBlogPostModel, bool canPop = false}) {
|
||||
Widget createTestWidget({
|
||||
FeedBlogPostModel? feedBlogPostModel,
|
||||
bool canPop = false,
|
||||
}) {
|
||||
final router = GoRouter(
|
||||
initialLocation: '/blog-post-create',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (context, state) => const Scaffold(body: Center(child: Text('Home'))),
|
||||
builder: (context, state) =>
|
||||
const Scaffold(body: Center(child: Text('Home'))),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/blog-post-create',
|
||||
builder: (context, state) => MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<BlogPostCreateCubit>.value(value: blogPostCreateCubit),
|
||||
BlocProvider<BlogPostCreateCubit>.value(
|
||||
value: blogPostCreateCubit,
|
||||
),
|
||||
BlocProvider<FeedCubit>.value(value: mockFeedCubit),
|
||||
],
|
||||
child: BlogPostCreateScreen(feedBlogPostModel: feedBlogPostModel),
|
||||
@@ -87,28 +104,46 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('displays all form fields when creating new blog post', (tester) async {
|
||||
testWidgets('displays all form fields when creating new blog post', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Заголовок'), findsOneWidget);
|
||||
expect(find.text('Описание'), findsOneWidget);
|
||||
expect(find.text('Изображение поста'), findsOneWidget);
|
||||
expect(find.textContaining('Изображения'), findsOneWidget);
|
||||
expect(find.text('Ссылка на видео'), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('pre-fills form fields when editing existing blog post', (tester) async {
|
||||
testWidgets('pre-fills form fields when editing existing blog post', (
|
||||
tester,
|
||||
) async {
|
||||
final existingPost = FeedBlogPostModel(
|
||||
id: 1,
|
||||
title: 'Test Title',
|
||||
description: 'Test Description',
|
||||
videoUrl: 'https://youtube.com/watch?v=test',
|
||||
image: '', // Changed: removed image so video field is visible
|
||||
videoUrl: 'https://rutube.ru/video/12345/',
|
||||
image: '',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true),
|
||||
author: const Author(
|
||||
authorSlug: 'test-author',
|
||||
authorName: 'Test Author',
|
||||
authorProfileIcon: 'avatar.jpg',
|
||||
authorIsMe: true,
|
||||
),
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
totalLikes: 0,
|
||||
@@ -117,24 +152,299 @@ void main() {
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost));
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(feedBlogPostModel: existingPost),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final titleField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenTitleTextField));
|
||||
final titleField = tester.widget<CustomTextFormField>(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
);
|
||||
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');
|
||||
|
||||
final videoFieldFinder = find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField);
|
||||
if (videoFieldFinder.evaluate().isNotEmpty) {
|
||||
final videoField = tester.widget<CustomTextFormField>(videoFieldFinder);
|
||||
expect(videoField.controller?.text, 'https://youtube.com/watch?v=test');
|
||||
}
|
||||
final videoField = tester.widget<CustomTextFormField>(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
);
|
||||
expect(videoField.controller?.text, 'https://rutube.ru/video/12345/');
|
||||
expect(find.text('Сохранить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides video field when image is present', (tester) async {
|
||||
testWidgets('shows video and gallery fields together', (tester) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
final existingPost = FeedBlogPostModel(
|
||||
id: 1,
|
||||
title: 'Test Title',
|
||||
description: 'Test Description',
|
||||
videoUrl: 'https://rutube.ru/video/12345/',
|
||||
image: 'http://localhost/storage/test_image.jpg',
|
||||
state: 'published',
|
||||
author: const Author(
|
||||
authorSlug: 'test-author',
|
||||
authorName: 'Test Author',
|
||||
authorProfileIcon: 'http://localhost/storage/avatar.jpg',
|
||||
authorIsMe: true,
|
||||
),
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
wasLikedByMe: false,
|
||||
images: const [
|
||||
FeedGalleryImage(
|
||||
id: 10,
|
||||
url: 'http://localhost/storage/test_image.jpg',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(feedBlogPostModel: existingPost),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Ссылка на видео'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('Изображения'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('allows entering text in title field', (tester) async {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
'New Title',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final titleField = tester.widget<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(
|
||||
id: 1,
|
||||
title: 'Test Title',
|
||||
@@ -154,136 +464,140 @@ void main() {
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
wasLikedByMe: false,
|
||||
images: const [
|
||||
FeedGalleryImage(
|
||||
id: 10,
|
||||
url: 'http://localhost/storage/test_image.jpg',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost));
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(feedBlogPostModel: existingPost),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Ссылка на видео'), findsNothing);
|
||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsNothing);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||
'https://youtube.com/watch?v=abc',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final saveButton = find.text('Сохранить');
|
||||
await tester.ensureVisible(saveButton);
|
||||
await tester.tap(saveButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(
|
||||
() => mockBlogPostRepository.updateBlogPost(
|
||||
id: any(named: 'id'),
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
existingMediaIds: any(named: 'existingMediaIds'),
|
||||
newImages: any(named: 'newImages'),
|
||||
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
find.text('Поддерживаются только Rutube, VK и OK'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('hides image field when video URL is present', (tester) async {
|
||||
final existingPost = FeedBlogPostModel(
|
||||
id: 1,
|
||||
title: 'Test Title',
|
||||
description: 'Test Description',
|
||||
videoUrl: 'https://youtube.com/watch?v=test',
|
||||
image: '',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true),
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Изображение поста'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('allows entering text in title field', (tester) async {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'New Title');
|
||||
await tester.pump();
|
||||
|
||||
final titleField = tester.widget<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 {
|
||||
testWidgets('does not submit update when form content is cleared', (
|
||||
tester,
|
||||
) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
when(mockBlogPostRepository.updateBlogPost(any, any, any, any, any, any)).thenAnswer((_) async {
|
||||
when(
|
||||
() => mockBlogPostRepository.updateBlogPost(
|
||||
id: any(named: 'id'),
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
existingMediaIds: any(named: 'existingMediaIds'),
|
||||
newImages: any(named: 'newImages'),
|
||||
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
|
||||
final existingPost = FeedBlogPostModel(
|
||||
id: 1,
|
||||
title: 'Test Title',
|
||||
description: 'Test Description',
|
||||
videoUrl: '',
|
||||
image: '',
|
||||
state: 'published',
|
||||
author: const Author(
|
||||
authorSlug: 'test-author',
|
||||
authorName: 'Test Author',
|
||||
authorProfileIcon: 'http://localhost/storage/avatar.jpg',
|
||||
authorIsMe: true,
|
||||
),
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(feedBlogPostModel: existingPost),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||
'',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||
'',
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final saveButton = find.text('Сохранить');
|
||||
await tester.ensureVisible(saveButton);
|
||||
await tester.tap(saveButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verifyNever(
|
||||
() => mockBlogPostRepository.updateBlogPost(
|
||||
id: any(named: 'id'),
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
existingMediaIds: any(named: 'existingMediaIds'),
|
||||
newImages: any(named: 'newImages'),
|
||||
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
find.text(
|
||||
'Добавьте заголовок, описание, изображение или ссылку на видео',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('calls updateBlogPost when editing existing post', (
|
||||
tester,
|
||||
) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
when(
|
||||
() => mockBlogPostRepository.updateBlogPost(
|
||||
id: any(named: 'id'),
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
existingMediaIds: any(named: 'existingMediaIds'),
|
||||
newImages: any(named: 'newImages'),
|
||||
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||
),
|
||||
).thenAnswer((_) async {
|
||||
return;
|
||||
});
|
||||
|
||||
@@ -306,26 +620,46 @@ void main() {
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
wasLikedByMe: false,
|
||||
images: const [
|
||||
FeedGalleryImage(
|
||||
id: 10,
|
||||
url: 'http://localhost/storage/test_image.jpg',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost, canPop: true));
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(feedBlogPostModel: existingPost, canPop: true),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final titleField = find.byKey(AppKeys.blogPostCreateScreenTitleTextField);
|
||||
final titleField = find.byKey(
|
||||
AppKeys.blogPostCreateScreenTitleTextField,
|
||||
);
|
||||
expect(titleField, findsOneWidget);
|
||||
|
||||
await tester.enterText(titleField, 'Updated Title');
|
||||
await tester.pump();
|
||||
|
||||
final createButton = find.text('Создать');
|
||||
await tester.ensureVisible(createButton);
|
||||
await tester.tap(createButton, warnIfMissed: false);
|
||||
final saveButton = find.text('Сохранить');
|
||||
await tester.ensureVisible(saveButton);
|
||||
await tester.tap(saveButton, warnIfMissed: false);
|
||||
await tester.pump();
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(mockBlogPostRepository.updateBlogPost(any, any, any, any, any, any)).called(1);
|
||||
verify(
|
||||
() => mockBlogPostRepository.updateBlogPost(
|
||||
id: any(named: 'id'),
|
||||
title: any(named: 'title'),
|
||||
videoUrl: any(named: 'videoUrl'),
|
||||
description: any(named: 'description'),
|
||||
existingMediaIds: any(named: 'existingMediaIds'),
|
||||
newImages: any(named: 'newImages'),
|
||||
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -344,8 +678,9 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('renders error state', (tester) async {
|
||||
|
||||
blogPostCreateCubit.emit(const BlogPostCreateState.error(errorMessage: 'Error creating post'));
|
||||
blogPostCreateCubit.emit(
|
||||
const BlogPostCreateState(errorMessage: 'Error creating post'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.pumpAndSettle();
|
||||
@@ -354,7 +689,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('renders loading state', (tester) async {
|
||||
blogPostCreateCubit.emit(const BlogPostCreateState.loading());
|
||||
blogPostCreateCubit.emit(const BlogPostCreateState(isLoading: true));
|
||||
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -25,204 +25,172 @@ void main() {
|
||||
test('deleteBlogPost вызывает сервис', () async {
|
||||
const id = 1;
|
||||
|
||||
when(() => mockService.deleteBlogPost(id: id)).thenAnswer((_) async => {});
|
||||
when(
|
||||
() => mockService.deleteBlogPost(id: id),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.deleteBlogPost(id);
|
||||
|
||||
verify(() => mockService.deleteBlogPost(id: id)).called(1);
|
||||
});
|
||||
|
||||
test('deleteBlogPostMedia вызывает сервис', () async {
|
||||
when(
|
||||
() => mockService.deleteBlogPostMedia(postId: 10, mediaId: 20),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.deleteBlogPostMedia(postId: 10, mediaId: 20);
|
||||
|
||||
verify(
|
||||
() => mockService.deleteBlogPostMedia(postId: 10, mediaId: 20),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost вызывает сервис с FormData', () async {
|
||||
const title = 'Test Title';
|
||||
const description = 'Test Description';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(title, null, description, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('updateBlogPost вызывает сервис с FormData', () async {
|
||||
const id = 1;
|
||||
const title = 'Updated Title';
|
||||
|
||||
when(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
() => mockService.createBlogPost(formData: any(named: 'formData')),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.updateBlogPost(id, title, null, null, null, null);
|
||||
await repository.createBlogPost(
|
||||
title: 'Test Title',
|
||||
description: 'Test Description',
|
||||
);
|
||||
|
||||
verify(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
final captured =
|
||||
verify(
|
||||
() => mockService.createBlogPost(
|
||||
formData: captureAny(named: 'formData'),
|
||||
),
|
||||
).captured.single
|
||||
as FormData;
|
||||
|
||||
test('createBlogPost - обрабатывает пустые поля', () async {
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(null, null, null, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает только title', () async {
|
||||
const title = 'Test Title';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(title, null, null, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
expect(
|
||||
captured.fields.map((e) => '${e.key}=${e.value}'),
|
||||
containsAll(['title=Test Title', 'description_md=Test Description']),
|
||||
);
|
||||
expect(captured.files, isEmpty);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает только videoUrl', () async {
|
||||
const videoUrl = 'https://example.com/video.mp4';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(null, videoUrl, null, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает только description', () async {
|
||||
const description = 'Test Description';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(null, null, description, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('updateBlogPost - обрабатывает все поля', () async {
|
||||
const id = 1;
|
||||
const title = 'Updated Title';
|
||||
const videoUrl = 'https://example.com/video.mp4';
|
||||
const description = 'Updated Description';
|
||||
const videoUrl = 'https://rutube.ru/video/12345/';
|
||||
|
||||
when(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
() => mockService.createBlogPost(formData: any(named: 'formData')),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.updateBlogPost(id, title, videoUrl, description, null, null);
|
||||
await repository.createBlogPost(videoUrl: videoUrl);
|
||||
|
||||
verify(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).called(1);
|
||||
final captured =
|
||||
verify(
|
||||
() => mockService.createBlogPost(
|
||||
formData: captureAny(named: 'formData'),
|
||||
),
|
||||
).captured.single
|
||||
as FormData;
|
||||
|
||||
expect(
|
||||
captured.fields.map((e) => '${e.key}=${e.value}'),
|
||||
contains('video_url=$videoUrl'),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'updateBlogPost отправляет existing_media_ids и удаляет media пачкой',
|
||||
() async {
|
||||
when(
|
||||
() => mockService.deleteBlogPostMedia(
|
||||
postId: 1,
|
||||
mediaId: any(named: 'mediaId'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
when(
|
||||
() => mockService.updateBlogPost(
|
||||
id: 1,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.updateBlogPost(
|
||||
id: 1,
|
||||
title: 'Updated Title',
|
||||
existingMediaIds: [5, 3],
|
||||
mediaIdsToDelete: [9, 8],
|
||||
);
|
||||
|
||||
verify(
|
||||
() => mockService.deleteBlogPostMedia(postId: 1, mediaId: 9),
|
||||
).called(1);
|
||||
verify(
|
||||
() => mockService.deleteBlogPostMedia(postId: 1, mediaId: 8),
|
||||
).called(1);
|
||||
|
||||
final captured =
|
||||
verify(
|
||||
() => mockService.updateBlogPost(
|
||||
id: 1,
|
||||
formData: captureAny(named: 'formData'),
|
||||
),
|
||||
).captured.single
|
||||
as FormData;
|
||||
|
||||
final fields = captured.fields
|
||||
.map((e) => '${e.key}=${e.value}')
|
||||
.toList();
|
||||
expect(fields, contains('title=Updated Title'));
|
||||
expect(fields, contains('description_md='));
|
||||
expect(fields, contains('video_url='));
|
||||
expect(fields, contains('existing_media_ids[]=5'));
|
||||
expect(fields, contains('existing_media_ids[]=3'));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'updateBlogPost отправляет пустые text fields для очистки на бэке',
|
||||
() async {
|
||||
when(
|
||||
() => mockService.updateBlogPost(
|
||||
id: 1,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.updateBlogPost(
|
||||
id: 1,
|
||||
title: '',
|
||||
description: '',
|
||||
videoUrl: '',
|
||||
existingMediaIds: const [],
|
||||
);
|
||||
|
||||
final captured =
|
||||
verify(
|
||||
() => mockService.updateBlogPost(
|
||||
id: 1,
|
||||
formData: captureAny(named: 'formData'),
|
||||
),
|
||||
).captured.single
|
||||
as FormData;
|
||||
|
||||
final fields = captured.fields
|
||||
.map((e) => '${e.key}=${e.value}')
|
||||
.toList();
|
||||
expect(fields, contains('title='));
|
||||
expect(fields, contains('description_md='));
|
||||
expect(fields, contains('video_url='));
|
||||
},
|
||||
);
|
||||
|
||||
test('updateBlogPost - обрабатывает пустые поля', () async {
|
||||
const id = 1;
|
||||
|
||||
when(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
() =>
|
||||
mockService.updateBlogPost(id: 1, formData: any(named: 'formData')),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.updateBlogPost(id, null, null, null, null, null);
|
||||
await repository.updateBlogPost(id: 1);
|
||||
|
||||
verify(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает пустые поля', () async {
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(null, null, null, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает только title', () async {
|
||||
const title = 'Test Title';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(title, null, null, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает только videoUrl', () async {
|
||||
const videoUrl = 'https://example.com/video.mp4';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(null, videoUrl, null, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает только description', () async {
|
||||
const description = 'Test Description';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(null, null, description, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('updateBlogPost - обрабатывает все поля', () async {
|
||||
const id = 1;
|
||||
const title = 'Updated Title';
|
||||
const videoUrl = 'https://example.com/video.mp4';
|
||||
const description = 'Updated Description';
|
||||
|
||||
when(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.updateBlogPost(id, title, videoUrl, description, null, null);
|
||||
|
||||
verify(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('updateBlogPost - обрабатывает пустые поля', () async {
|
||||
const id = 1;
|
||||
|
||||
when(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.updateBlogPost(id, null, null, null, null, null);
|
||||
|
||||
verify(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
() =>
|
||||
mockService.updateBlogPost(id: 1, formData: any(named: 'formData')),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,9 @@ void main() {
|
||||
test('createBlogPost - создает пост блога', () async {
|
||||
final formData = FormData.fromMap({});
|
||||
|
||||
when(mockBlogPostService.createBlogPost(formData: formData))
|
||||
.thenAnswer((_) async => Future.value());
|
||||
when(
|
||||
mockBlogPostService.createBlogPost(formData: formData),
|
||||
).thenAnswer((_) async => Future.value());
|
||||
|
||||
await mockBlogPostService.createBlogPost(formData: formData);
|
||||
|
||||
@@ -30,19 +31,35 @@ void main() {
|
||||
const id = 1;
|
||||
final formData = FormData.fromMap({});
|
||||
|
||||
when(mockBlogPostService.updateBlogPost(id: id, formData: formData))
|
||||
.thenAnswer((_) async => Future.value());
|
||||
when(
|
||||
mockBlogPostService.updateBlogPost(id: id, formData: formData),
|
||||
).thenAnswer((_) async => Future.value());
|
||||
|
||||
await mockBlogPostService.updateBlogPost(id: id, formData: formData);
|
||||
|
||||
verify(mockBlogPostService.updateBlogPost(id: id, formData: formData)).called(1);
|
||||
verify(
|
||||
mockBlogPostService.updateBlogPost(id: id, formData: formData),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('deleteBlogPostMedia - удаляет изображение поста', () async {
|
||||
when(
|
||||
mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2),
|
||||
).thenAnswer((_) async => Future.value());
|
||||
|
||||
await mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2);
|
||||
|
||||
verify(
|
||||
mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('deleteBlogPost - удаляет пост блога', () async {
|
||||
const id = 1;
|
||||
|
||||
when(mockBlogPostService.deleteBlogPost(id: id))
|
||||
.thenAnswer((_) async => Future.value());
|
||||
when(
|
||||
mockBlogPostService.deleteBlogPost(id: id),
|
||||
).thenAnswer((_) async => Future.value());
|
||||
|
||||
await mockBlogPostService.deleteBlogPost(id: id);
|
||||
|
||||
@@ -50,4 +67,3 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.copyWith(inn: '123').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);
|
||||
});
|
||||
|
||||
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(
|
||||
() => repository.registerConsultant(
|
||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||
@@ -473,7 +496,10 @@ void main() {
|
||||
|
||||
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(
|
||||
() => repository.registerConsultant(
|
||||
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 {
|
||||
when(
|
||||
() => 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/pages/passport_data_screen.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_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -60,5 +61,19 @@ void main() {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
group('FeedBlogPostModel', () {
|
||||
test('creates FeedBlogPostModel with all parameters', () {
|
||||
@@ -102,6 +104,65 @@ void main() {
|
||||
expect(model.totalLikes, 10);
|
||||
});
|
||||
|
||||
test('parses v2 show JSON with gallery images and video_url_embed', () {
|
||||
final model = FeedBlogPostModel.fromJson({
|
||||
'id': 1,
|
||||
'state': 'active',
|
||||
'image': 'media/1/cover.jpg',
|
||||
'images': [
|
||||
{'id': 10, 'url': 'media/1/a.jpg', 'position': 1, 'orientation': 'horizontal'},
|
||||
{'id': 11, 'url': 'media/1/b.jpg', 'position': 2},
|
||||
],
|
||||
'description': 'Описание',
|
||||
'author': {
|
||||
'author_slug': 'author-slug',
|
||||
'author_name': 'Автор',
|
||||
'author_profile_icon': 'icon.jpg',
|
||||
'author_is_me': false,
|
||||
},
|
||||
'video_url': 'https://rutube.ru/video/abc/',
|
||||
'video_url_embed': 'https://rutube.ru/play/embed/abc/',
|
||||
'video_url_thumb': 'media/1/thumb.jpg',
|
||||
'created_at': '2024-01-15T12:00:00Z',
|
||||
'updated_at': '2024-01-15T12:00:00Z',
|
||||
'title': 'Заголовок',
|
||||
'total_likes': 10,
|
||||
'total_views': 100,
|
||||
'total_comments': 5,
|
||||
'was_liked_by_me': false,
|
||||
});
|
||||
|
||||
expect(model.images, hasLength(2));
|
||||
expect(model.images.first.id, 10);
|
||||
expect(model.images.first.url, 'media/1/a.jpg');
|
||||
expect(model.images.first.orientation, 'horizontal');
|
||||
expect(model.images[1].orientation, 'horizontal');
|
||||
expect(model.images[1].position, 2);
|
||||
expect(model.videoEmbedUrl, 'https://rutube.ru/play/embed/abc/');
|
||||
expect(model.videoUrlThumb, 'media/1/thumb.jpg');
|
||||
});
|
||||
|
||||
test('parses fixture feedBlogPostModel with gallery and embed', () {
|
||||
final model = FeedFixtures.feedBlogPostModel();
|
||||
|
||||
expect(model.images, hasLength(2));
|
||||
expect(model.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(model.videoUrlThumb, 'media/1/thumb.jpg');
|
||||
});
|
||||
|
||||
test('parses profile search_by_author fixture with gallery and video', () {
|
||||
final posts = FeedFixtures.blogPostsByAuthor();
|
||||
|
||||
expect(posts, hasLength(2));
|
||||
expect(posts.first.images, hasLength(2));
|
||||
expect(posts.first.images.first.orientation, 'horizontal');
|
||||
expect(posts.first.images[1].orientation, 'vertical');
|
||||
expect(posts.first.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(posts.first.videoUrlThumb, 'media/1/thumb.jpg');
|
||||
expect(posts[1].images, hasLength(2));
|
||||
expect(posts[1].videoEmbedUrl, isNull);
|
||||
});
|
||||
|
||||
test('creates FeedBlogPostModel with different states', () {
|
||||
const author = Author(
|
||||
authorSlug: 'author',
|
||||
|
||||
56
test/features/feed/models/post_feed_mapper_test.dart
Normal file
56
test/features/feed/models/post_feed_mapper_test.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
group('PostFeedMapper', () {
|
||||
setUpAll(() async {
|
||||
await sl.reset();
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
});
|
||||
|
||||
test('maps fixture item post_id to FeedBlogPostModel.id', () {
|
||||
final item = FeedFixtures.postFeedItem();
|
||||
final model = PostFeedMapper.fromPostFeedItem(item, currentUserId: 10);
|
||||
|
||||
expect(model.id, 1);
|
||||
expect(model.author.authorIsMe, isTrue);
|
||||
expect(model.author.authorSlug, 'anna-ivanova');
|
||||
expect(model.images, hasLength(2));
|
||||
expect(model.videoUrl, isEmpty);
|
||||
expect(model.videoEmbedUrl, isNull);
|
||||
expect(model.title, 'Завтрак с овсянкой');
|
||||
expect(model.image, 'http://localhost/storage/posts/oatmeal.jpg');
|
||||
expect(model.author.isEntitySubscribedByMe, isFalse);
|
||||
});
|
||||
|
||||
test('maps fixture embed video post and likes', () {
|
||||
final item = FeedFixtures.postFeedItem(index: 1);
|
||||
final model = PostFeedMapper.fromPostFeedItem(item);
|
||||
|
||||
expect(model.id, 2);
|
||||
expect(model.videoUrl, isEmpty);
|
||||
expect(model.videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ');
|
||||
expect(model.wasLikedByMe, isTrue);
|
||||
expect(model.totalLikes, 42);
|
||||
expect(model.author.authorIsMe, isFalse);
|
||||
expect(model.author.isEntitySubscribedByMe, isTrue);
|
||||
});
|
||||
|
||||
test('maps all fixture posts', () {
|
||||
final posts = FeedFixtures.mappedFeedPosts(currentUserId: 11);
|
||||
|
||||
expect(posts, hasLength(3));
|
||||
expect(posts[1].author.authorIsMe, isTrue);
|
||||
expect(posts[1].author.isEntitySubscribedByMe, isTrue);
|
||||
expect(posts[2].videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(posts[2].author.isEntitySubscribedByMe, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
|
||||
void main() {
|
||||
group('PostFeedPaginationModel fixtures', () {
|
||||
test('parses first page with gallery, video and next_cursor', () {
|
||||
final model = FeedFixtures.postFeedPaginationModel();
|
||||
|
||||
expect(model.data, hasLength(3));
|
||||
expect(model.meta.perPage, 20);
|
||||
expect(model.meta.nextCursor, 'eyJpZCI6MTAzfQ');
|
||||
expect(model.meta.prevCursor, isNull);
|
||||
expect(model.links.next, contains('cursor=eyJpZCI6MTAzfQ'));
|
||||
|
||||
final withGallery = model.data[0];
|
||||
expect(withGallery.postId, 1);
|
||||
expect(withGallery.images, hasLength(2));
|
||||
expect(withGallery.images.first.orientation, 'horizontal');
|
||||
expect(withGallery.videoEmbedUrl, isNull);
|
||||
expect(withGallery.isEntitySubscribedByMe, isFalse);
|
||||
expect(withGallery.postAuthorDetails?.slug, 'anna-ivanova');
|
||||
expect(withGallery.postAuthorDetails?.isConsultant, isTrue);
|
||||
|
||||
final withYoutubeEmbed = model.data[1];
|
||||
expect(withYoutubeEmbed.postId, 2);
|
||||
expect(withYoutubeEmbed.videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ');
|
||||
expect(withYoutubeEmbed.postIsLikedByUser, isTrue);
|
||||
expect(withYoutubeEmbed.isEntitySubscribedByMe, isTrue);
|
||||
expect(withYoutubeEmbed.images, isEmpty);
|
||||
|
||||
final withRutubeEmbed = model.data[2];
|
||||
expect(withRutubeEmbed.postId, 3);
|
||||
expect(withRutubeEmbed.postImageUrl, isNull);
|
||||
expect(withRutubeEmbed.images, hasLength(1));
|
||||
expect(withRutubeEmbed.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(withRutubeEmbed.isEntitySubscribedByMe, isNull); });
|
||||
|
||||
test('parses next page without next_cursor', () {
|
||||
final model = FeedFixtures.postFeedNextPagePaginationModel();
|
||||
|
||||
expect(model.data, hasLength(1));
|
||||
expect(model.data.first.postId, 4);
|
||||
expect(model.meta.nextCursor, isNull);
|
||||
expect(model.meta.prevCursor, 'eyJpZCI6MTAzfQ');
|
||||
expect(model.links.next, isNull);
|
||||
});
|
||||
|
||||
test('parses empty feed', () {
|
||||
final model = FeedFixtures.postFeedEmptyPaginationModel();
|
||||
|
||||
expect(model.data, isEmpty);
|
||||
expect(model.meta.nextCursor, isNull);
|
||||
expect(model.links.next, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:eatvio_app/core/models/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_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
@@ -13,211 +11,52 @@ import 'feed_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<FeedRepository>()])
|
||||
void main() {
|
||||
group('FeedCubit', () {
|
||||
FeedCubit? feedCubit;
|
||||
late MockFeedRepository mockFeedRepository;
|
||||
late FeedBlogPostSettingsModel feedBlogPostsFixture;
|
||||
group('FeedCubit cursor flow', () {
|
||||
late FeedCubit cubit;
|
||||
late MockFeedRepository repository;
|
||||
|
||||
final post = FeedBlogPostModel(
|
||||
id: 10,
|
||||
state: 'active',
|
||||
image: '',
|
||||
description: 'description',
|
||||
author: const Author(authorSlug: 'slug', authorName: 'name', authorProfileIcon: '', authorIsMe: false),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
updatedAt: DateTime(2024, 1, 1),
|
||||
title: 'title',
|
||||
totalLikes: 1,
|
||||
totalViews: 1,
|
||||
totalComments: 1,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
mockFeedRepository = MockFeedRepository();
|
||||
|
||||
// Создаём тестовые данные
|
||||
final testPosts = [
|
||||
FeedBlogPostModel(
|
||||
id: 1,
|
||||
state: 'published',
|
||||
image: 'https://example.com/image1.jpg',
|
||||
description: 'Описание поста 1',
|
||||
author: const Author(
|
||||
authorSlug: 'author-1',
|
||||
authorName: 'Автор 1',
|
||||
authorProfileIcon: 'https://example.com/photo1.jpg',
|
||||
authorIsMe: false,
|
||||
isEntitySubscribedByMe: null,
|
||||
authorPlaceInRating: null,
|
||||
),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
title: 'Тестовый пост 1',
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
wasLikedByMe: false,
|
||||
),
|
||||
FeedBlogPostModel(
|
||||
id: 2,
|
||||
state: 'published',
|
||||
image: 'https://example.com/image2.jpg',
|
||||
description: 'Описание поста 2',
|
||||
author: const Author(
|
||||
authorSlug: 'author-2',
|
||||
authorName: 'Автор 2',
|
||||
authorProfileIcon: 'https://example.com/photo2.jpg',
|
||||
authorIsMe: false,
|
||||
isEntitySubscribedByMe: null,
|
||||
authorPlaceInRating: null,
|
||||
),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
title: 'Тестовый пост 2',
|
||||
totalLikes: 20,
|
||||
totalViews: 200,
|
||||
totalComments: 10,
|
||||
wasLikedByMe: true,
|
||||
),
|
||||
];
|
||||
|
||||
feedBlogPostsFixture = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: testPosts,
|
||||
meta: const Meta(
|
||||
currentPage: 1,
|
||||
from: null,
|
||||
lastPage: 3,
|
||||
links: [],
|
||||
path: null,
|
||||
perPage: 10,
|
||||
to: null,
|
||||
total: 25,
|
||||
totalMinPrice: null,
|
||||
totalMaxPrice: null,
|
||||
),
|
||||
);
|
||||
repository = MockFeedRepository();
|
||||
cubit = FeedCubit(feedRepository: repository);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (feedCubit != null && !feedCubit!.isClosed) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
feedCubit!.close();
|
||||
feedCubit = null;
|
||||
}
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('начальное состояние - FeedStateLoading', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
test('error state on load failure', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenThrow(Exception('boom'));
|
||||
|
||||
await cubit.loadFeedBlogPostList();
|
||||
|
||||
expect(cubit.state, isA<FeedStateError>());
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - успешная загрузка постов', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
test('updateFeedBlogPostList updates likes in loaded state', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: null));
|
||||
await cubit.loadFeedBlogPostList();
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null))
|
||||
.thenAnswer((_) async => feedBlogPostsFixture);
|
||||
cubit.updateFeedBlogPostList(post, true);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 2);
|
||||
expect(loadedState.isLoading, false);
|
||||
expect(feedCubit!.lastPage, 3);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - загрузка с запросом', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const query = 'тест';
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: query))
|
||||
.thenAnswer((_) async => feedBlogPostsFixture);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList(query: query);
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
verify(mockFeedRepository.getFeedBlogPosts(query: query)).called(1);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - обработка ошибки', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: anyNamed('query')))
|
||||
.thenThrow(Exception('Ошибка загрузки'));
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
expect(feedCubit!.state, isA<FeedStateError>());
|
||||
final errorState = feedCubit!.state as FeedStateError;
|
||||
expect(errorState.errorMessage, contains('Exception'));
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - обновление лайка поста', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
// Сначала загружаем посты
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: anyNamed('query')))
|
||||
.thenAnswer((_) async => feedBlogPostsFixture);
|
||||
|
||||
// Устанавливаем состояние loaded вручную для теста
|
||||
feedCubit!.emit(FeedState.loaded(
|
||||
feedBlogPostList: feedBlogPostsFixture.feedBlogPostList,
|
||||
isLoading: false,
|
||||
));
|
||||
feedCubit!.lastPage = 3;
|
||||
|
||||
final postToUpdate = feedBlogPostsFixture.feedBlogPostList.first;
|
||||
feedCubit!.updateFeedBlogPostList(postToUpdate, true);
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
final updatedPost = loadedState.feedBlogPostList.firstWhere((p) => p.id == postToUpdate.id);
|
||||
expect(updatedPost.wasLikedByMe, true);
|
||||
expect(updatedPost.totalLikes, postToUpdate.totalLikes + 1);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - снятие лайка', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
feedCubit!.emit(FeedState.loaded(
|
||||
feedBlogPostList: feedBlogPostsFixture.feedBlogPostList,
|
||||
isLoading: false,
|
||||
));
|
||||
feedCubit!.lastPage = 3;
|
||||
|
||||
final postToUpdate = feedBlogPostsFixture.feedBlogPostList[1]; // У этого wasLikedByMe = true
|
||||
final initialLikes = postToUpdate.totalLikes;
|
||||
|
||||
feedCubit!.updateFeedBlogPostList(postToUpdate, false);
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
final updatedPost = loadedState.feedBlogPostList.firstWhere((p) => p.id == postToUpdate.id);
|
||||
expect(updatedPost.wasLikedByMe, false);
|
||||
expect(updatedPost.totalLikes, initialLikes - 1);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - игнорирует обновление если состояние не loaded', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
// Состояние loading
|
||||
feedCubit!.emit(const FeedState.loading());
|
||||
|
||||
final postToUpdate = feedBlogPostsFixture.feedBlogPostList.first;
|
||||
feedCubit!.updateFeedBlogPostList(postToUpdate, true);
|
||||
|
||||
// Состояние не должно измениться
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - игнорирует обновление если пост не найден', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
|
||||
feedCubit!.emit(FeedState.loaded(
|
||||
feedBlogPostList: feedBlogPostsFixture.feedBlogPostList,
|
||||
isLoading: false,
|
||||
));
|
||||
feedCubit!.lastPage = 3;
|
||||
|
||||
// Создаём пост с несуществующим ID
|
||||
final nonExistentPost = feedBlogPostsFixture.feedBlogPostList.first.copyWith(id: 999);
|
||||
feedCubit!.updateFeedBlogPostList(nonExistentPost, true);
|
||||
|
||||
// Список не должен измениться
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 2);
|
||||
final loaded = cubit.state as FeedStateLoaded;
|
||||
expect(loaded.feedBlogPostList.first.wasLikedByMe, true);
|
||||
expect(loaded.feedBlogPostList.first.totalLikes, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,404 +1,66 @@
|
||||
import 'package:eatvio_app/core/models/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_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'feed_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<FeedRepository>()])
|
||||
void main() {
|
||||
group('FeedCubit', () {
|
||||
FeedCubit? feedCubit;
|
||||
late MockFeedRepository mockFeedRepository;
|
||||
late MockFeedRepository repository;
|
||||
late FeedCubit cubit;
|
||||
|
||||
final testBlogPost = FeedBlogPostModel(
|
||||
final post = FeedBlogPostModel(
|
||||
id: 1,
|
||||
title: 'Test Post',
|
||||
description: 'Test description',
|
||||
image: 'https://example.com/image.jpg',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
createdAt: DateTime(2024, 1, 15),
|
||||
updatedAt: DateTime(2024, 1, 15),
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
state: 'active',
|
||||
image: 'https://example.com/image.png',
|
||||
description: 'text',
|
||||
author: const Author(authorSlug: 'a', authorName: 'Author', authorProfileIcon: 'https://example.com/avatar.png', authorIsMe: false),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
updatedAt: DateTime(2024, 1, 1),
|
||||
title: 'Title',
|
||||
totalLikes: 1,
|
||||
totalViews: 2,
|
||||
totalComments: 3,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
final testBlogPost2 = FeedBlogPostModel(
|
||||
id: 2,
|
||||
title: 'Test Post 2',
|
||||
description: 'Test description 2',
|
||||
image: 'https://example.com/image2.jpg',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'author-2', authorName: 'Author Name 2', authorProfileIcon: 'https://example.com/icon2.jpg', authorIsMe: false),
|
||||
videoUrl: 'https://example.com/video2.mp4',
|
||||
createdAt: DateTime(2024, 1, 16),
|
||||
updatedAt: DateTime(2024, 1, 16),
|
||||
totalLikes: 20,
|
||||
totalViews: 200,
|
||||
totalComments: 10,
|
||||
wasLikedByMe: true,
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
mockFeedRepository = MockFeedRepository();
|
||||
repository = MockFeedRepository();
|
||||
cubit = FeedCubit(feedRepository: repository);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (feedCubit != null && !feedCubit!.isClosed) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
feedCubit!.close();
|
||||
feedCubit = null;
|
||||
}
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('начальное состояние - loading', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
test('loadFeedBlogPostList emits loaded from getFeed', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next'));
|
||||
|
||||
await cubit.loadFeedBlogPostList();
|
||||
|
||||
expect(cubit.state, isA<FeedStateLoaded>());
|
||||
final loaded = cubit.state as FeedStateLoaded;
|
||||
expect(loaded.feedBlogPostList, hasLength(1));
|
||||
verify(repository.getFeed(search: null, cursor: null, currentUserId: null)).called(1);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - успешно загружает список постов', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final expectedSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
test('loadNextPage appends posts', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next'));
|
||||
when(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).thenAnswer((_) async => (posts: [post.copyWith(id: 2)], nextCursor: null));
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => expectedSettings);
|
||||
await cubit.loadFeedBlogPostList();
|
||||
await cubit.loadNextPage();
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 1);
|
||||
expect(loadedState.feedBlogPostList.first.id, 1);
|
||||
expect(loadedState.isLoading, false);
|
||||
expect(feedCubit!.lastPage, 3);
|
||||
verify(mockFeedRepository.getFeedBlogPosts(query: null)).called(1);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - загружает список постов с query', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const query = 'test query';
|
||||
final expectedSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost, testBlogPost2],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 2, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: query)).thenAnswer((_) async => expectedSettings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList(query: query);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 2);
|
||||
verify(mockFeedRepository.getFeedBlogPosts(query: query)).called(1);
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - обрабатывает ошибку', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final exception = Exception('Network error');
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenThrow(exception);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateError>());
|
||||
final errorState = feedCubit!.state as FeedStateError;
|
||||
expect(errorState.errorMessage, contains('Exception: Network error'));
|
||||
});
|
||||
|
||||
test('loadNextPage - загружает следующую страницу', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final firstPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
final secondPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost2],
|
||||
meta: const Meta(currentPage: 2, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => firstPageSettings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).thenAnswer((_) async => secondPageSettings);
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.length, 2);
|
||||
expect(feedCubit!.currentPage, 2);
|
||||
expect(loadedState.isLoading, false);
|
||||
verify(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).called(1);
|
||||
});
|
||||
|
||||
test('loadNextPage - не загружает если уже на последней странице', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
verifyNever(mockFeedRepository.getFeedBlogPosts(page: 2, query: anyNamed('query')));
|
||||
});
|
||||
|
||||
test('loadNextPage - не загружает если уже загружается', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
feedCubit!.isLoading = true;
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
verifyNever(mockFeedRepository.getFeedBlogPosts(page: 2, query: anyNamed('query')));
|
||||
});
|
||||
|
||||
test('loadNextPage - обрабатывает ошибку при загрузке следующей страницы', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final firstPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => firstPageSettings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final exception = Exception('Network error');
|
||||
when(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).thenThrow(exception);
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateError>());
|
||||
final errorState = feedCubit!.state as FeedStateError;
|
||||
expect(errorState.errorMessage, contains('Exception: Network error'));
|
||||
});
|
||||
|
||||
test('loadNextPage - сохраняет query при загрузке следующей страницы', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const query = 'test query';
|
||||
final firstPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
final secondPageSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost2],
|
||||
meta: const Meta(currentPage: 2, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: query)).thenAnswer((_) async => firstPageSettings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList(query: query);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(page: 2, query: query)).thenAnswer((_) async => secondPageSettings);
|
||||
|
||||
await feedCubit!.loadNextPage();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
verify(mockFeedRepository.getFeedBlogPosts(page: 2, query: query)).called(1);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - обновляет лайк поста', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
feedCubit!.updateFeedBlogPostList(testBlogPost, true);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.first.wasLikedByMe, true);
|
||||
expect(loadedState.feedBlogPostList.first.totalLikes, 11);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - убирает лайк поста', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost2],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
feedCubit!.updateFeedBlogPostList(testBlogPost2, false);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, isA<FeedStateLoaded>());
|
||||
final loadedState = feedCubit!.state as FeedStateLoaded;
|
||||
expect(loadedState.feedBlogPostList.first.wasLikedByMe, false);
|
||||
expect(loadedState.feedBlogPostList.first.totalLikes, 19);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - не обновляет если пост не найден', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final nonExistentPost = testBlogPost.copyWith(id: 999);
|
||||
final initialState = feedCubit!.state;
|
||||
feedCubit!.updateFeedBlogPostList(nonExistentPost, true);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, initialState);
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - не обновляет если state не loaded', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
feedCubit!.updateFeedBlogPostList(testBlogPost, true);
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
});
|
||||
|
||||
test('updateFeedBlogPostList - не обновляет если post null', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final settings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final initialState = feedCubit!.state;
|
||||
feedCubit!.updateFeedBlogPostList(null, true);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(feedCubit!.state, initialState);
|
||||
});
|
||||
|
||||
test('likePost - лайкает пост', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const postId = 1;
|
||||
|
||||
when(mockFeedRepository.likeBlogPost(id: postId)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.likePost(postId);
|
||||
|
||||
verify(mockFeedRepository.likeBlogPost(id: postId)).called(1);
|
||||
});
|
||||
|
||||
test('likePost - обрабатывает ошибку без выбрасывания', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const postId = 1;
|
||||
final exception = Exception('Network error');
|
||||
|
||||
when(mockFeedRepository.likeBlogPost(id: postId)).thenThrow(exception);
|
||||
|
||||
await feedCubit!.likePost(postId);
|
||||
|
||||
verify(mockFeedRepository.likeBlogPost(id: postId)).called(1);
|
||||
});
|
||||
|
||||
test('unlikedPost - убирает лайк с поста', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const postId = 1;
|
||||
|
||||
when(mockFeedRepository.unlikedBlogPost(id: postId)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.unlikedPost(postId);
|
||||
|
||||
verify(mockFeedRepository.unlikedBlogPost(id: postId)).called(1);
|
||||
});
|
||||
|
||||
test('viewPost - отправляет просмотр поста', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const postId = 1;
|
||||
|
||||
when(mockFeedRepository.viewPost(postId)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.viewPost(postId);
|
||||
|
||||
verify(mockFeedRepository.viewPost(postId)).called(1);
|
||||
});
|
||||
|
||||
test('viewRecipe - отправляет просмотр рецепта', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const slug = 'recipe-1';
|
||||
|
||||
when(mockFeedRepository.viewRecipe(slug)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.viewRecipe(slug);
|
||||
|
||||
verify(mockFeedRepository.viewRecipe(slug)).called(1);
|
||||
});
|
||||
|
||||
test('viewDiet - отправляет просмотр диеты', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const dietId = 2;
|
||||
|
||||
when(mockFeedRepository.viewDiet(dietId)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.viewDiet(dietId);
|
||||
|
||||
verify(mockFeedRepository.viewDiet(dietId)).called(1);
|
||||
});
|
||||
|
||||
test('viewPlan - отправляет просмотр плана', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
const slug = 'plan-1';
|
||||
|
||||
when(mockFeedRepository.viewPlan(slug)).thenAnswer((_) async => Future.value());
|
||||
|
||||
await feedCubit!.viewPlan(slug);
|
||||
|
||||
verify(mockFeedRepository.viewPlan(slug)).called(1);
|
||||
final loaded = cubit.state as FeedStateLoaded;
|
||||
expect(loaded.feedBlogPostList, hasLength(2));
|
||||
verify(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/models/pagination_model.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.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_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_posts.dart';
|
||||
@@ -378,12 +376,8 @@ void main() {
|
||||
// Arrange
|
||||
feedCubit.emit(FeedState.loaded(feedBlogPostList: [testBlogPost], isLoading: false));
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer(
|
||||
(_) async => FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [testBlogPost, testBlogPost2],
|
||||
meta: const Meta(currentPage: 1, lastPage: 1, total: 2, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
),
|
||||
);
|
||||
when(mockFeedRepository.getFeed(search: null, cursor: null, currentUserId: null))
|
||||
.thenAnswer((_) async => (posts: [testBlogPost, testBlogPost2], nextCursor: null));
|
||||
|
||||
final widget = BlogPosts(handleVisibility: ({required visibleFraction, required id, required type}) {});
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ void main() {
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await tester.pumpWidget(BlocProvider<FeedCubit>.value(value: cubit, child: wrap(const BlogPostWidget(index: 0))));
|
||||
await tester.pumpWidget(BlocProvider<FeedCubit>.value(value: cubit, child: wrap(BlogPostWidget(post: mockBlogPost))));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:eatvio_app/core/models/pagination_model.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/feed/models/comment_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_settings_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
@@ -11,9 +13,11 @@ import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/services/feed_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
import '../../../fixtures/fixture_reader.dart';
|
||||
import 'feed_repository_test.mocks.dart';
|
||||
|
||||
@@ -23,72 +27,57 @@ void main() {
|
||||
late FeedRepository feedRepository;
|
||||
late MockFeedService mockFeedService;
|
||||
|
||||
setUp(() {
|
||||
setUp(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
mockFeedService = MockFeedService();
|
||||
feedRepository = FeedRepository(feedService: mockFeedService);
|
||||
});
|
||||
|
||||
test('getFeedBlogPosts - успешное получение постов', () async {
|
||||
const expectedModel = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [],
|
||||
meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], path: null, perPage: 10, to: null, total: 0, totalMinPrice: null, totalMaxPrice: null),
|
||||
);
|
||||
test('getFeed - успешное получение постов', () async {
|
||||
final expectedModel = FeedFixtures.postFeedPaginationModel();
|
||||
|
||||
when(mockFeedService.getBlogPostList(page: 1, query: null)).thenAnswer((_) async => expectedModel);
|
||||
when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expectedModel);
|
||||
|
||||
final result = await feedRepository.getFeedBlogPosts();
|
||||
final result = await feedRepository.getFeed();
|
||||
|
||||
expect(result, isA<FeedBlogPostSettingsModel>());
|
||||
verify(mockFeedService.getBlogPostList(page: 1, query: null)).called(1);
|
||||
expect(result.posts, hasLength(3));
|
||||
expect(result.posts.first.id, 1);
|
||||
expect(result.posts[1].videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ');
|
||||
expect(result.nextCursor, 'eyJpZCI6MTAzfQ');
|
||||
verify(mockFeedService.getFeed(search: null, cursor: null)).called(1);
|
||||
});
|
||||
|
||||
test('getFeedBlogPosts - с запросом и страницей', () async {
|
||||
test('getFeed - с запросом и курсором', () async {
|
||||
const query = 'тест';
|
||||
const page = 2;
|
||||
const expectedModel = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [],
|
||||
meta: Meta(
|
||||
currentPage: page,
|
||||
from: null,
|
||||
lastPage: 2,
|
||||
links: [],
|
||||
path: null,
|
||||
perPage: 10,
|
||||
to: null,
|
||||
total: 15,
|
||||
totalMinPrice: null,
|
||||
totalMaxPrice: null,
|
||||
),
|
||||
);
|
||||
const cursor = 'eyJpZCI6MTAzfQ';
|
||||
final expectedModel = FeedFixtures.postFeedNextPagePaginationModel();
|
||||
|
||||
when(mockFeedService.getBlogPostList(page: page, query: query)).thenAnswer((_) async => expectedModel);
|
||||
when(mockFeedService.getFeed(search: query, cursor: cursor)).thenAnswer((_) async => expectedModel);
|
||||
|
||||
final result = await feedRepository.getFeedBlogPosts(query: query, page: page);
|
||||
final result = await feedRepository.getFeed(search: query, cursor: cursor);
|
||||
|
||||
expect(result, isA<FeedBlogPostSettingsModel>());
|
||||
verify(mockFeedService.getBlogPostList(page: page, query: query)).called(1);
|
||||
expect(result.posts, hasLength(1));
|
||||
expect(result.posts.first.id, 4);
|
||||
expect(result.nextCursor, isNull);
|
||||
verify(mockFeedService.getFeed(search: query, cursor: cursor)).called(1);
|
||||
});
|
||||
|
||||
test('getFeedBlogPosts - возвращает список постов без query', () async {
|
||||
const page = 1;
|
||||
const expectedSettings = FeedBlogPostSettingsModel(
|
||||
feedBlogPostList: [],
|
||||
meta: Meta(currentPage: 1, lastPage: 1, total: 0, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
|
||||
);
|
||||
test('getFeed - пустая лента', () async {
|
||||
final expectedModel = FeedFixtures.postFeedEmptyPaginationModel();
|
||||
|
||||
when(mockFeedService.getBlogPostList(page: page, query: null)).thenAnswer((_) async => expectedSettings);
|
||||
when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expectedModel);
|
||||
|
||||
final result = await feedRepository.getFeedBlogPosts(page: page);
|
||||
final result = await feedRepository.getFeed();
|
||||
|
||||
expect(result, isA<FeedBlogPostSettingsModel>());
|
||||
expect(result.feedBlogPostList.isEmpty, true);
|
||||
verify(mockFeedService.getBlogPostList(page: page, query: null)).called(1);
|
||||
expect(result.posts, isEmpty);
|
||||
expect(result.nextCursor, isNull);
|
||||
});
|
||||
|
||||
test('getBlogPost - успешное получение поста', () async {
|
||||
const postId = 1;
|
||||
final json = FixtureReader.readAsMap('feed/feed_blog_post_response.json');
|
||||
final expectedPost = FeedBlogPostModel.fromJson(json);
|
||||
final expectedPost = FeedFixtures.feedBlogPostModel();
|
||||
|
||||
when(mockFeedService.getBlogPost(postId: postId)).thenAnswer((_) async => expectedPost);
|
||||
|
||||
@@ -96,6 +85,9 @@ void main() {
|
||||
|
||||
expect(result, isA<FeedBlogPostModel>());
|
||||
expect(result.id, postId);
|
||||
expect(result.images, hasLength(2));
|
||||
expect(result.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(result.videoUrlThumb, isNotEmpty);
|
||||
verify(mockFeedService.getBlogPost(postId: postId)).called(1);
|
||||
});
|
||||
|
||||
@@ -529,10 +521,10 @@ void main() {
|
||||
verify(mockFeedService.viewContent(viewModel: viewModel)).called(1);
|
||||
});
|
||||
|
||||
test('getFeedBlogPosts - пробрасывает исключение', () async {
|
||||
when(mockFeedService.getBlogPostList(page: 1, query: null)).thenThrow(Exception('Ошибка сети'));
|
||||
test('getFeed - пробрасывает исключение', () async {
|
||||
when(mockFeedService.getFeed(search: null, cursor: null)).thenThrow(Exception('Ошибка сети'));
|
||||
|
||||
expect(() => feedRepository.getFeedBlogPosts(), throwsException);
|
||||
expect(() => feedRepository.getFeed(), throwsException);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
import 'feed_service_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<FeedService>()])
|
||||
@@ -55,6 +56,32 @@ void main() {
|
||||
verify(mockFeedService.getBlogPostList(page: page, query: null)).called(1);
|
||||
});
|
||||
|
||||
test('getFeed returns cursor response', () async {
|
||||
final expected = FeedFixtures.postFeedPaginationModel();
|
||||
|
||||
when(mockFeedService.getFeed(search: null, cursor: null)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await mockFeedService.getFeed(search: null, cursor: null);
|
||||
|
||||
expect(result.data, hasLength(3));
|
||||
expect(result.meta.perPage, 20);
|
||||
expect(result.meta.nextCursor, 'eyJpZCI6MTAzfQ');
|
||||
verify(mockFeedService.getFeed(search: null, cursor: null)).called(1);
|
||||
});
|
||||
|
||||
test('getFeed returns next page without cursor', () async {
|
||||
const cursor = 'eyJpZCI6MTAzfQ';
|
||||
final expected = FeedFixtures.postFeedNextPagePaginationModel();
|
||||
|
||||
when(mockFeedService.getFeed(search: null, cursor: cursor)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await mockFeedService.getFeed(search: null, cursor: cursor);
|
||||
|
||||
expect(result.data, hasLength(1));
|
||||
expect(result.meta.nextCursor, isNull);
|
||||
verify(mockFeedService.getFeed(search: null, cursor: cursor)).called(1);
|
||||
});
|
||||
|
||||
test('getBlogPost returns post', () async {
|
||||
const id = 10;
|
||||
final expected = FeedBlogPostModel(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/payment_flow.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -26,8 +29,17 @@ void main() {
|
||||
late bool? gateResult;
|
||||
late bool formCalled;
|
||||
late PromoCodeResult? formResult;
|
||||
late PromoCodeModel? formInitialPromo;
|
||||
late bool sbpCalled;
|
||||
late int sbpPlanId;
|
||||
late int validateCalls;
|
||||
late int applyCalls;
|
||||
late String? lastValidatedCode;
|
||||
late String? lastAppliedCode;
|
||||
late PromoValidateResponse validateResponse;
|
||||
late PromoApplyResponse applyResponse;
|
||||
late bool applyThrows;
|
||||
late PromoCodeModel? listAppliedResult;
|
||||
|
||||
Future<bool?> mockGate(BuildContext context) async {
|
||||
gateCalled = true;
|
||||
@@ -37,8 +49,27 @@ void main() {
|
||||
Future<PromoCodeResult?> mockForm(
|
||||
BuildContext context, {
|
||||
Future<PromoValidation> Function(String)? onValidate,
|
||||
Future<bool> Function()? onRemove,
|
||||
PromoCodeModel? initialPromo,
|
||||
}) async {
|
||||
formCalled = true;
|
||||
formInitialPromo = initialPromo;
|
||||
|
||||
if (formResult?.hasPromoCode == true &&
|
||||
!formResult!.alreadyApplied &&
|
||||
onValidate != null) {
|
||||
final validation = await onValidate(formResult!.code!);
|
||||
if (!validation.isValid) {
|
||||
return null;
|
||||
}
|
||||
return PromoCodeResult(
|
||||
code: formResult!.code,
|
||||
discountPercent: validation.discountPercent,
|
||||
discountAmountRub: validation.discountAmountRub,
|
||||
originalAmountRub: validation.originalAmountRub,
|
||||
finalAmountRub: validation.finalAmountRub,
|
||||
);
|
||||
}
|
||||
return formResult;
|
||||
}
|
||||
|
||||
@@ -47,31 +78,86 @@ void main() {
|
||||
sbpPlanId = planId;
|
||||
}
|
||||
|
||||
Future<PromoValidateResponse> mockValidate({
|
||||
required int planId,
|
||||
required String promoCode,
|
||||
}) async {
|
||||
validateCalls++;
|
||||
lastValidatedCode = promoCode;
|
||||
return validateResponse;
|
||||
}
|
||||
|
||||
Future<PromoApplyResponse> mockApply({
|
||||
required int planId,
|
||||
required String promoCode,
|
||||
}) async {
|
||||
applyCalls++;
|
||||
lastAppliedCode = promoCode;
|
||||
if (applyThrows) {
|
||||
throw Exception('network');
|
||||
}
|
||||
return applyResponse;
|
||||
}
|
||||
|
||||
Future<PromoCodeModel?> mockListApplied(int planId) async {
|
||||
return listAppliedResult;
|
||||
}
|
||||
|
||||
Future<bool> mockRemove(int planId) async {
|
||||
return true;
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
gateCalled = false;
|
||||
gateResult = null;
|
||||
formCalled = false;
|
||||
formResult = null;
|
||||
formInitialPromo = null;
|
||||
sbpCalled = false;
|
||||
sbpPlanId = 0;
|
||||
validateCalls = 0;
|
||||
applyCalls = 0;
|
||||
lastValidatedCode = null;
|
||||
lastAppliedCode = null;
|
||||
listAppliedResult = null;
|
||||
validateResponse = const PromoValidateResponse(
|
||||
valid: true,
|
||||
promoCode: 'TEST123',
|
||||
discountPercent: 5,
|
||||
discountAmountRub: 60,
|
||||
originalAmountRub: 1200,
|
||||
finalAmountRub: 1140,
|
||||
);
|
||||
applyResponse = const PromoApplyResponse(success: true);
|
||||
applyThrows = false;
|
||||
});
|
||||
|
||||
testWidgets('Нет → вызывает SbpBanks (не форму)', (tester) async {
|
||||
gateResult = false;
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await showConsultationPaymentFlow(
|
||||
Future<void> runFlow(WidgetTester tester) {
|
||||
return showConsultationPaymentFlow(
|
||||
tester.element(find.byType(SizedBox)),
|
||||
planId: 42,
|
||||
showGate: mockGate,
|
||||
showForm: mockForm,
|
||||
showSbp: mockSbp,
|
||||
validatePromo: mockValidate,
|
||||
applyPromo: mockApply,
|
||||
listApplied: mockListApplied,
|
||||
removePromo: mockRemove,
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('Нет → вызывает SbpBanks (не форму)', (tester) async {
|
||||
gateResult = false;
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await runFlow(tester);
|
||||
|
||||
expect(gateCalled, isTrue);
|
||||
expect(formCalled, isFalse);
|
||||
expect(sbpCalled, isTrue);
|
||||
expect(sbpPlanId, 42);
|
||||
expect(validateCalls, 0);
|
||||
expect(applyCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets('Да → форма → Пропустить → вызывает SbpBanks', (tester) async {
|
||||
@@ -79,18 +165,13 @@ void main() {
|
||||
formResult = const PromoCodeResult();
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await showConsultationPaymentFlow(
|
||||
tester.element(find.byType(SizedBox)),
|
||||
planId: 42,
|
||||
showGate: mockGate,
|
||||
showForm: mockForm,
|
||||
showSbp: mockSbp,
|
||||
);
|
||||
await runFlow(tester);
|
||||
|
||||
expect(gateCalled, isTrue);
|
||||
expect(formCalled, isTrue);
|
||||
expect(sbpCalled, isTrue);
|
||||
expect(sbpPlanId, 42);
|
||||
expect(applyCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets('Да → форма отменена (null) → Sbp не вызывается', (tester) async {
|
||||
@@ -98,13 +179,7 @@ void main() {
|
||||
formResult = null;
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await showConsultationPaymentFlow(
|
||||
tester.element(find.byType(SizedBox)),
|
||||
planId: 42,
|
||||
showGate: mockGate,
|
||||
showForm: mockForm,
|
||||
showSbp: mockSbp,
|
||||
);
|
||||
await runFlow(tester);
|
||||
|
||||
expect(gateCalled, isTrue);
|
||||
expect(formCalled, isTrue);
|
||||
@@ -115,17 +190,121 @@ void main() {
|
||||
gateResult = null;
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await showConsultationPaymentFlow(
|
||||
tester.element(find.byType(SizedBox)),
|
||||
planId: 42,
|
||||
showGate: mockGate,
|
||||
showForm: mockForm,
|
||||
showSbp: mockSbp,
|
||||
);
|
||||
await runFlow(tester);
|
||||
|
||||
expect(gateCalled, isTrue);
|
||||
expect(formCalled, isFalse);
|
||||
expect(sbpCalled, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('Да → код → validate + apply → SbpBanks', (tester) async {
|
||||
gateResult = true;
|
||||
formResult = const PromoCodeResult(code: 'TEST123');
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await runFlow(tester);
|
||||
|
||||
expect(validateCalls, 1);
|
||||
expect(lastValidatedCode, 'TEST123');
|
||||
expect(applyCalls, 1);
|
||||
expect(lastAppliedCode, 'TEST123');
|
||||
expect(sbpCalled, isTrue);
|
||||
expect(sbpPlanId, 42);
|
||||
});
|
||||
|
||||
testWidgets('Да → код → apply fail → Sbp не вызывается', (tester) async {
|
||||
gateResult = true;
|
||||
formResult = const PromoCodeResult(code: 'BAD');
|
||||
applyResponse = const PromoApplyResponse(
|
||||
success: false,
|
||||
error: 'Промокод уже использован',
|
||||
);
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await runFlow(tester);
|
||||
|
||||
expect(applyCalls, 1);
|
||||
expect(sbpCalled, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('applied rehydrate → Continue → SBP без apply и без гейта', (tester) async {
|
||||
listAppliedResult = const PromoCodeModel(
|
||||
id: 1,
|
||||
chosenPlanId: 42,
|
||||
promoCode: 'SAVE5',
|
||||
discountPercent: 5,
|
||||
discountAmountRub: 60,
|
||||
state: 'applied',
|
||||
);
|
||||
formResult = const PromoCodeResult(
|
||||
code: 'SAVE5',
|
||||
discountPercent: 5,
|
||||
discountAmountRub: 60,
|
||||
alreadyApplied: true,
|
||||
);
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await runFlow(tester);
|
||||
|
||||
expect(gateCalled, isFalse);
|
||||
expect(formCalled, isTrue);
|
||||
expect(formInitialPromo?.promoCode, 'SAVE5');
|
||||
expect(applyCalls, 0);
|
||||
expect(sbpCalled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('list пустой → идёт через гейт', (tester) async {
|
||||
listAppliedResult = null;
|
||||
gateResult = false;
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await runFlow(tester);
|
||||
|
||||
expect(gateCalled, isTrue);
|
||||
expect(formCalled, isFalse);
|
||||
expect(sbpCalled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('rehydrate → Remove → новый код → apply → SBP', (tester) async {
|
||||
listAppliedResult = const PromoCodeModel(
|
||||
id: 1,
|
||||
chosenPlanId: 42,
|
||||
promoCode: 'OLD',
|
||||
discountPercent: 5,
|
||||
discountAmountRub: 60,
|
||||
state: 'applied',
|
||||
);
|
||||
formResult = const PromoCodeResult(code: 'NEW123');
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await runFlow(tester);
|
||||
|
||||
expect(gateCalled, isFalse);
|
||||
expect(formCalled, isTrue);
|
||||
expect(validateCalls, 1);
|
||||
expect(applyCalls, 1);
|
||||
expect(lastAppliedCode, 'NEW123');
|
||||
expect(sbpCalled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('rehydrate → Remove → Skip → SBP без apply', (tester) async {
|
||||
listAppliedResult = const PromoCodeModel(
|
||||
id: 1,
|
||||
chosenPlanId: 42,
|
||||
promoCode: 'OLD',
|
||||
discountPercent: 5,
|
||||
discountAmountRub: 60,
|
||||
state: 'applied',
|
||||
);
|
||||
formResult = const PromoCodeResult();
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await runFlow(tester);
|
||||
|
||||
expect(gateCalled, isFalse);
|
||||
expect(formCalled, isTrue);
|
||||
expect(applyCalls, 0);
|
||||
expect(sbpCalled, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
108
test/features/payment/models/promo_response_models_test.dart
Normal file
108
test/features/payment/models/promo_response_models_test.dart
Normal file
@@ -0,0 +1,108 @@
|
||||
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('PromoValidateResponse', () {
|
||||
test('parses valid preview', () {
|
||||
final response = PromoValidateResponse.fromJson({
|
||||
'valid': true,
|
||||
'promo_code': 'PROMO_100117_A',
|
||||
'discount_percent': 5,
|
||||
'discount_amount_rub': 60.0,
|
||||
'original_amount_rub': 1200.0,
|
||||
'final_amount_rub': 1140.0,
|
||||
});
|
||||
|
||||
expect(response.valid, isTrue);
|
||||
expect(response.promoCode, 'PROMO_100117_A');
|
||||
expect(response.discountPercent, 5);
|
||||
expect(response.finalAmountRub, 1140.0);
|
||||
expect(response.error, isNull);
|
||||
});
|
||||
|
||||
test('parses invalid preview with HTTP 200 body', () {
|
||||
final response = PromoValidateResponse.fromJson({
|
||||
'valid': false,
|
||||
'error': 'Промокод «UNKNOWN» не найден в системе.',
|
||||
});
|
||||
|
||||
expect(response.valid, isFalse);
|
||||
expect(response.error, contains('не найден'));
|
||||
});
|
||||
});
|
||||
|
||||
group('PromoApplyResponse', () {
|
||||
test('parses success with nested promo_code', () {
|
||||
final response = PromoApplyResponse.fromJson({
|
||||
'success': true,
|
||||
'promo_code': {
|
||||
'id': 1,
|
||||
'chosen_plan_id': 42,
|
||||
'promo_code': 'PROMO_100117_A',
|
||||
'discount_percent': 5,
|
||||
'discount_amount_rub': 60.0,
|
||||
'state': 'applied',
|
||||
'applied_at': '2024-01-15T10:30:00+00:00',
|
||||
'confirmed_at': null,
|
||||
'cancelled_at': null,
|
||||
},
|
||||
'discount_percent': 5,
|
||||
'discount_amount_rub': 60.0,
|
||||
'original_amount_rub': 1200.0,
|
||||
'final_amount_rub': 1140.0,
|
||||
});
|
||||
|
||||
expect(response.success, isTrue);
|
||||
expect(response.promoCode, isA<PromoCodeModel>());
|
||||
expect(response.promoCode!.promoCode, 'PROMO_100117_A');
|
||||
expect(response.promoCode!.state, 'applied');
|
||||
expect(response.finalAmountRub, 1140.0);
|
||||
});
|
||||
|
||||
test('parses failure body', () {
|
||||
final response = PromoApplyResponse.fromJson({
|
||||
'success': false,
|
||||
'error': 'Промокод «X» уже был использован вами ранее.',
|
||||
});
|
||||
|
||||
expect(response.success, isFalse);
|
||||
expect(response.error, contains('уже был использован'));
|
||||
expect(response.promoCode, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('PromoAppliedListResponse', () {
|
||||
test('parses promo_codes list', () {
|
||||
final response = PromoAppliedListResponse.fromJson({
|
||||
'promo_codes': [
|
||||
{
|
||||
'id': 2,
|
||||
'chosen_plan_id': 42,
|
||||
'promo_code': 'PROMO_LIST_B',
|
||||
'discount_percent': 3,
|
||||
'discount_amount_rub': 36.0,
|
||||
'state': 'applied',
|
||||
'applied_at': '2024-01-15T10:30:00+00:00',
|
||||
'confirmed_at': null,
|
||||
'cancelled_at': null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.promoCodes, hasLength(1));
|
||||
expect(response.promoCodes.first.promoCode, 'PROMO_LIST_B');
|
||||
expect(response.promoCodes.first.chosenPlanId, 42);
|
||||
});
|
||||
|
||||
test('parses empty list', () {
|
||||
final response = PromoAppliedListResponse.fromJson({
|
||||
'promo_codes': <dynamic>[],
|
||||
});
|
||||
|
||||
expect(response.promoCodes, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -11,6 +12,8 @@ void main() {
|
||||
group('ItvioPromoCodeModal', () {
|
||||
Widget createTestWidget({
|
||||
Future<PromoValidation> Function(String)? onValidate,
|
||||
Future<bool> Function()? onRemove,
|
||||
PromoCodeModel? initialPromo,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
@@ -26,6 +29,8 @@ void main() {
|
||||
onPressed: () => ItvioPromoCodeModal.show(
|
||||
context,
|
||||
onValidate: onValidate,
|
||||
onRemove: onRemove,
|
||||
initialPromo: initialPromo,
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
@@ -88,14 +93,84 @@ void main() {
|
||||
expect(find.text('Есть промокод?'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('отсутствует UI applied-состояния (Промокод применён)', (tester) async {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
testWidgets('initialPromo показывает applied UI', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(
|
||||
initialPromo: const PromoCodeModel(
|
||||
id: 1,
|
||||
chosenPlanId: 42,
|
||||
promoCode: 'SAVE5',
|
||||
discountPercent: 5,
|
||||
discountAmountRub: 60,
|
||||
state: 'applied',
|
||||
),
|
||||
onRemove: () async => true,
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Промокод применён!'), findsOneWidget);
|
||||
expect(find.text('Продолжить'), findsOneWidget);
|
||||
expect(find.text('Убрать промокод'), findsOneWidget);
|
||||
expect(find.text('Применить'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Убрать промокод переводит в input', (tester) async {
|
||||
var removeCalled = false;
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(
|
||||
initialPromo: const PromoCodeModel(
|
||||
id: 1,
|
||||
chosenPlanId: 42,
|
||||
promoCode: 'SAVE5',
|
||||
discountPercent: 5,
|
||||
discountAmountRub: 60,
|
||||
state: 'applied',
|
||||
),
|
||||
onRemove: () async {
|
||||
removeCalled = true;
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Убрать промокод'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(removeCalled, isTrue);
|
||||
expect(find.text('Есть промокод?'), findsOneWidget);
|
||||
expect(find.text('Применить'), findsOneWidget);
|
||||
expect(find.text('Пропустить'), findsOneWidget);
|
||||
expect(find.text('Промокод применён!'), findsNothing);
|
||||
expect(find.text('Продолжить'), findsNothing);
|
||||
expect(find.text('Убрать промокод'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Применить с успешным onValidate закрывает шторку', (tester) async {
|
||||
String? validatedCode;
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(
|
||||
onValidate: (code) async {
|
||||
validatedCode = code;
|
||||
return const PromoValidation.success(
|
||||
discountPercent: 5,
|
||||
discountAmountRub: 60,
|
||||
originalAmountRub: 1200,
|
||||
finalAmountRub: 1140,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'save5');
|
||||
await tester.tap(find.text('Применить'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(validatedCode, 'SAVE5');
|
||||
expect(find.text('Есть промокод?'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||
@@ -10,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../../../fixtures/feed_fixtures.dart';
|
||||
import 'profile_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([
|
||||
@@ -267,20 +267,10 @@ void main() {
|
||||
authorPlaceInRating: null,
|
||||
);
|
||||
|
||||
final blogPost = FeedBlogPostModel(
|
||||
id: 1,
|
||||
state: 'published',
|
||||
title: 'Test Post',
|
||||
description: 'Test Content',
|
||||
image: 'https://example.com/image.jpg',
|
||||
final blogPost = FeedFixtures.feedBlogPostGalleryAndVideo().copyWith(
|
||||
author: author,
|
||||
videoUrl: '',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
totalLikes: 10,
|
||||
totalComments: 5,
|
||||
totalViews: 100,
|
||||
wasLikedByMe: false,
|
||||
totalLikes: 10,
|
||||
);
|
||||
|
||||
const profileDetails = ProfileDetailsModel(
|
||||
|
||||
@@ -5,20 +5,20 @@ import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
|
||||
import '../../../../fixtures/feed_fixtures.dart';
|
||||
import 'profile_blog_post_widget_smoke_test.mocks.dart';
|
||||
|
||||
class _FakeHttpClient implements HttpClient {
|
||||
@@ -35,7 +35,6 @@ class _FakeHttpClient implements HttpClient {
|
||||
@override
|
||||
Future<HttpClientRequest> getUrl(Uri url) async => _FakeHttpClientRequest();
|
||||
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -48,7 +47,8 @@ class _FakeHttpClientRequest implements HttpClientRequest {
|
||||
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.
|
||||
static final List<int> _png = <int>[
|
||||
0x89,
|
||||
@@ -127,11 +127,22 @@ class _FakeHttpClientResponse extends Stream<List<int>> implements HttpClientRes
|
||||
int get contentLength => _png.length;
|
||||
|
||||
@override
|
||||
HttpClientResponseCompressionState get compressionState => HttpClientResponseCompressionState.notCompressed;
|
||||
HttpClientResponseCompressionState get compressionState =>
|
||||
HttpClientResponseCompressionState.notCompressed;
|
||||
|
||||
@override
|
||||
StreamSubscription<List<int>> listen(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);
|
||||
StreamSubscription<List<int>> listen(
|
||||
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
|
||||
@@ -142,84 +153,183 @@ class _FakeHttpClientResponse extends Stream<List<int>> implements HttpClientRes
|
||||
void main() {
|
||||
group('ProfileBlogPostWidget smoke', () {
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() =>
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders blog post card', (tester) async {
|
||||
final post = FeedBlogPostModel(
|
||||
id: 1,
|
||||
state: 'published',
|
||||
image: 'test.png',
|
||||
description: 'Hello',
|
||||
author: const Author(
|
||||
authorSlug: 'author',
|
||||
authorName: 'Author',
|
||||
authorProfileIcon: 'https://example.com/icon.png',
|
||||
authorIsMe: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
const profile = ProfileModel(
|
||||
id: 1,
|
||||
name: 'User',
|
||||
email: 'u@e.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
slug: 'user',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: '',
|
||||
roles: [],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
|
||||
const details = ProfileDetailsModel(
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
birthdate: '1990-01-01',
|
||||
gender: 'male',
|
||||
country: 'Russia',
|
||||
city: 'Moscow',
|
||||
lang: 'ru',
|
||||
weight: 70.0,
|
||||
height: 175,
|
||||
chestCircumference: 100,
|
||||
waistCircumference: 80,
|
||||
hipCircumference: 90,
|
||||
bloodPressureUp: 120,
|
||||
bloodPressureDown: 80,
|
||||
hasHypo: false,
|
||||
hasHyper: false,
|
||||
aboutAlergy: '',
|
||||
aboutTakingPils: '',
|
||||
aboutSportHobbies: '',
|
||||
favoriteFoods: '',
|
||||
unfavoriteFoods: '',
|
||||
additionalInfo: '',
|
||||
isMoodFillable: true,
|
||||
isBedtimeFillable: true,
|
||||
activityCoefficient: 1.5,
|
||||
);
|
||||
|
||||
Future<void> pumpPost(WidgetTester tester, ProfileCubit cubit) async {
|
||||
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: ProfileBlogPostWidget(index: 0, profileCubit: cubit),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 200));
|
||||
}, createHttpClient: (_) => _FakeHttpClient());
|
||||
}
|
||||
|
||||
testWidgets('renders gallery and video media content', (tester) async {
|
||||
final post = FeedFixtures.feedBlogPostGalleryAndVideo();
|
||||
final loadedState = ProfileState.loaded(
|
||||
profile: profile,
|
||||
profileDetails: details,
|
||||
blogPosts: [post],
|
||||
isMyProfile: true,
|
||||
certPhotos: [],
|
||||
);
|
||||
final cubit = MockProfileCubit();
|
||||
when(cubit.state).thenReturn(loadedState);
|
||||
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await pumpPost(tester, cubit);
|
||||
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.textContaining('Галерея и видео'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('renders gallery-only media content', (tester) async {
|
||||
final post = FeedFixtures.feedBlogPostWithGallery();
|
||||
final loadedState = ProfileState.loaded(
|
||||
profile: profile,
|
||||
profileDetails: details,
|
||||
blogPosts: [post],
|
||||
isMyProfile: true,
|
||||
certPhotos: [],
|
||||
);
|
||||
final cubit = MockProfileCubit();
|
||||
when(cubit.state).thenReturn(loadedState);
|
||||
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await pumpPost(tester, cubit);
|
||||
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.text('Галерея'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders video-only media content', (tester) async {
|
||||
final post = FeedFixtures.feedBlogPostVideoOnly();
|
||||
final loadedState = ProfileState.loaded(
|
||||
profile: profile,
|
||||
profileDetails: details,
|
||||
blogPosts: [post],
|
||||
isMyProfile: true,
|
||||
certPhotos: [],
|
||||
);
|
||||
final cubit = MockProfileCubit();
|
||||
when(cubit.state).thenReturn(loadedState);
|
||||
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await pumpPost(tester, cubit);
|
||||
|
||||
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||
expect(find.text('Видео'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('cover-only posts with the same image use unique hero tags', (
|
||||
tester,
|
||||
) async {
|
||||
const sharedImage = 'https://example.com/same-cover.jpg';
|
||||
final posts = [
|
||||
FeedFixtures.feedBlogPostModel().copyWith(
|
||||
id: 11,
|
||||
title: 'Первый',
|
||||
description: 'Текст',
|
||||
image: sharedImage,
|
||||
images: const [],
|
||||
videoUrl: '',
|
||||
videoEmbedUrl: null,
|
||||
),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 1),
|
||||
title: 'Title',
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
wasLikedByMe: false,
|
||||
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: [],
|
||||
);
|
||||
|
||||
const profile = ProfileModel(
|
||||
id: 1,
|
||||
name: 'User',
|
||||
email: 'u@e.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
slug: 'user',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: '',
|
||||
roles: [],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
|
||||
const details = ProfileDetailsModel(
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
birthdate: '1990-01-01',
|
||||
gender: 'male',
|
||||
country: 'Russia',
|
||||
city: 'Moscow',
|
||||
lang: 'ru',
|
||||
weight: 70.0,
|
||||
height: 175,
|
||||
chestCircumference: 100,
|
||||
waistCircumference: 80,
|
||||
hipCircumference: 90,
|
||||
bloodPressureUp: 120,
|
||||
bloodPressureDown: 80,
|
||||
hasHypo: false,
|
||||
hasHyper: false,
|
||||
aboutAlergy: '',
|
||||
aboutTakingPils: '',
|
||||
aboutSportHobbies: '',
|
||||
favoriteFoods: '',
|
||||
unfavoriteFoods: '',
|
||||
additionalInfo: '',
|
||||
isMoodFillable: true,
|
||||
isBedtimeFillable: true,
|
||||
activityCoefficient: 1.5,
|
||||
);
|
||||
|
||||
final loadedState = ProfileState.loaded(profile: profile, profileDetails: details, blogPosts: [post], isMyProfile: true, certPhotos: []);
|
||||
final cubit = MockProfileCubit();
|
||||
when(cubit.state).thenReturn(loadedState);
|
||||
when(cubit.stream).thenAnswer((_) => const Stream<ProfileState>.empty());
|
||||
@@ -236,15 +346,36 @@ void main() {
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru')],
|
||||
home: Scaffold(body: ProfileBlogPostWidget(index: 0, profileCubit: cubit)),
|
||||
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: (context) => _FakeHttpClient());
|
||||
}, createHttpClient: (_) => _FakeHttpClient());
|
||||
|
||||
expect(find.text('Регистрация'), findsNothing);
|
||||
expect(find.text('Title'), findsOneWidget);
|
||||
expect(find.text('Author'), findsOneWidget);
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import 'package:eatvio_app/features/profile/models/circumference_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/weight_model.dart';
|
||||
import 'package:eatvio_app/features/profile/services/profile_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../../fixtures/feed_fixtures.dart';
|
||||
import 'profile_service_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<ProfileService>()])
|
||||
@@ -72,9 +74,17 @@ void main() {
|
||||
expect(await mockProfileService.getProfileDetailsBySlug(slug: 'slug'), isA<ProfileDetailsModel>());
|
||||
});
|
||||
|
||||
test('getBlogPostByAuthorSlug', () async {
|
||||
when(mockProfileService.getBlogPostByAuthorSlug(slug: '')).thenAnswer((_) async => []);
|
||||
expect(await mockProfileService.getBlogPostByAuthorSlug(slug: 'slug'), isA<List<FeedBlogPostModel>>());
|
||||
test('getBlogPostByAuthorSlug returns posts with gallery and video fields', () async {
|
||||
final posts = FeedFixtures.blogPostsByAuthor();
|
||||
when(mockProfileService.getBlogPostByAuthorSlug(slug: 'slug')).thenAnswer((_) async => posts);
|
||||
|
||||
final result = await mockProfileService.getBlogPostByAuthorSlug(slug: 'slug');
|
||||
|
||||
expect(result, isA<List<FeedBlogPostModel>>());
|
||||
expect(result, hasLength(2));
|
||||
expect(result.first.images, isNotEmpty);
|
||||
expect(result.first.videoEmbedUrl, isNotNull);
|
||||
expect(result.first.videoUrlThumb, isNotNull);
|
||||
});
|
||||
|
||||
test('getWeight', () async {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"title": "Complete Test Title",
|
||||
"description_md": "Complete Test Description",
|
||||
"video_url": "https://youtube.com/watch?v=complete",
|
||||
"video_url": "https://rutube.ru/video/12345/",
|
||||
"image_name": null
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"id": 1,
|
||||
"title": "Updated Title",
|
||||
"description_md": "Updated Description",
|
||||
"video_url": "https://youtube.com/watch?v=updated",
|
||||
"old_image_url": "original_image.jpg",
|
||||
"video_url": "https://vk.com/video-1_1",
|
||||
"existing_media_ids": [2, 1],
|
||||
"image_name": null
|
||||
}
|
||||
|
||||
|
||||
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';
|
||||
|
||||
class FixtureBlogPostRepository extends BlogPostRepository {
|
||||
FixtureBlogPostRepository()
|
||||
: super(blogPostService: _NoopBlogPostService());
|
||||
FixtureBlogPostRepository() : super(blogPostService: _NoopBlogPostService());
|
||||
|
||||
final List<BlogPostCreatePayload> createPayloads = <BlogPostCreatePayload>[];
|
||||
final List<BlogPostUpdatePayload> updatePayloads = <BlogPostUpdatePayload>[];
|
||||
final List<int> deleteCalls = <int>[];
|
||||
final List<({int postId, int mediaId})> deleteMediaCalls = <({int postId, int mediaId})>[];
|
||||
|
||||
Duration createDelay = Duration.zero;
|
||||
Duration updateDelay = Duration.zero;
|
||||
@@ -18,15 +18,21 @@ class FixtureBlogPostRepository extends BlogPostRepository {
|
||||
Object? createError;
|
||||
Object? updateError;
|
||||
Object? deleteError;
|
||||
Object? deleteMediaError;
|
||||
|
||||
@override
|
||||
Future<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(
|
||||
BlogPostCreatePayload(
|
||||
title: title,
|
||||
description: description,
|
||||
videoUrl: videoUrl,
|
||||
imageName: image?.name,
|
||||
imageName: images.isEmpty ? null : images.first.name,
|
||||
),
|
||||
);
|
||||
if (createDelay > Duration.zero) {
|
||||
@@ -38,15 +44,26 @@ class FixtureBlogPostRepository extends BlogPostRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<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(
|
||||
BlogPostUpdatePayload(
|
||||
id: id,
|
||||
title: title,
|
||||
description: description,
|
||||
videoUrl: videoUrl,
|
||||
imageName: image?.name,
|
||||
oldImageUrl: oldImageUrl,
|
||||
imageName: newImages.isEmpty ? null : newImages.first.name,
|
||||
existingMediaIds: existingMediaIds,
|
||||
),
|
||||
);
|
||||
if (updateDelay > Duration.zero) {
|
||||
@@ -97,7 +114,7 @@ class BlogPostUpdatePayload {
|
||||
required this.description,
|
||||
required this.videoUrl,
|
||||
required this.imageName,
|
||||
required this.oldImageUrl,
|
||||
required this.existingMediaIds,
|
||||
});
|
||||
|
||||
final int id;
|
||||
@@ -105,15 +122,15 @@ class BlogPostUpdatePayload {
|
||||
final String? description;
|
||||
final String? videoUrl;
|
||||
final String? imageName;
|
||||
final String? oldImageUrl;
|
||||
final List<int> existingMediaIds;
|
||||
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{
|
||||
'id': id,
|
||||
'title': title,
|
||||
'description_md': description,
|
||||
'video_url': videoUrl,
|
||||
'existing_media_ids': existingMediaIds,
|
||||
'image_name': imageName,
|
||||
'old_image_url': oldImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +145,11 @@ class _NoopBlogPostService implements BlogPostService {
|
||||
throw UnimplementedError('Fixture overrides repository methods directly.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteBlogPostMedia({required int postId, required int mediaId}) {
|
||||
throw UnimplementedError('Fixture overrides repository methods directly.');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateBlogPost({required int id, required FormData formData}) {
|
||||
throw UnimplementedError('Fixture overrides repository methods directly.');
|
||||
@@ -141,4 +163,3 @@ class FeedReloadProbe {
|
||||
loadRequests += 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
test/fixtures/feed/feed_blog_post_gallery_and_video_response.json
vendored
Normal file
39
test/fixtures/feed/feed_blog_post_gallery_and_video_response.json
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": 4,
|
||||
"state": "active",
|
||||
"image": "media/4/a.jpg",
|
||||
"images": [
|
||||
{
|
||||
"id": 40,
|
||||
"url": "media/4/a.jpg",
|
||||
"position": 1,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": 41,
|
||||
"url": "media/4/b.jpg",
|
||||
"position": 2,
|
||||
"orientation": "horizontal"
|
||||
}
|
||||
],
|
||||
"description": "Галерея и видео",
|
||||
"author": {
|
||||
"author_slug": "author-slug",
|
||||
"author_name": "Автор",
|
||||
"author_profile_icon": "https://example.com/photo.jpg",
|
||||
"author_is_me": false,
|
||||
"is_entity_subscribed_by_me": true,
|
||||
"author_place_in_rating": null
|
||||
},
|
||||
"video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"video_url_thumb": "media/4/thumb.jpg",
|
||||
"created_at": "2024-01-18T12:00:00Z",
|
||||
"updated_at": "2024-01-18T12:00:00Z",
|
||||
"title": "Галерея и видео",
|
||||
"total_likes": 12,
|
||||
"total_views": 200,
|
||||
"total_comments": 4,
|
||||
"was_liked_by_me": false,
|
||||
"is_favorite": false
|
||||
}
|
||||
45
test/fixtures/feed/feed_blog_post_gallery_response.json
vendored
Normal file
45
test/fixtures/feed/feed_blog_post_gallery_response.json
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"id": 2,
|
||||
"state": "active",
|
||||
"image": "media/2/a.jpg",
|
||||
"images": [
|
||||
{
|
||||
"id": 20,
|
||||
"url": "media/2/a.jpg",
|
||||
"position": 1,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"url": "media/2/b.jpg",
|
||||
"position": 2,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": 22,
|
||||
"url": "media/2/c.jpg",
|
||||
"position": 3,
|
||||
"orientation": "vertical"
|
||||
}
|
||||
],
|
||||
"description": "Пост только с галереей",
|
||||
"author": {
|
||||
"author_slug": "author-slug",
|
||||
"author_name": "Автор",
|
||||
"author_profile_icon": "https://example.com/photo.jpg",
|
||||
"author_is_me": false,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"author_place_in_rating": null
|
||||
},
|
||||
"video_url": "",
|
||||
"video_url_embed": null,
|
||||
"video_url_thumb": null,
|
||||
"created_at": "2024-01-16T12:00:00Z",
|
||||
"updated_at": "2024-01-16T12:00:00Z",
|
||||
"title": "Галерея",
|
||||
"total_likes": 3,
|
||||
"total_views": 40,
|
||||
"total_comments": 1,
|
||||
"was_liked_by_me": false,
|
||||
"is_favorite": false
|
||||
}
|
||||
26
test/fixtures/feed/feed_blog_post_response.json
vendored
26
test/fixtures/feed/feed_blog_post_response.json
vendored
@@ -1,7 +1,21 @@
|
||||
{
|
||||
"id": 1,
|
||||
"state": "published",
|
||||
"image": "https://example.com/image.jpg",
|
||||
"state": "active",
|
||||
"image": "media/1/cover.jpg",
|
||||
"images": [
|
||||
{
|
||||
"id": 10,
|
||||
"url": "media/1/a.jpg",
|
||||
"position": 1,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"url": "media/1/b.jpg",
|
||||
"position": 2,
|
||||
"orientation": "vertical"
|
||||
}
|
||||
],
|
||||
"description": "Описание поста",
|
||||
"author": {
|
||||
"author_slug": "author-slug",
|
||||
@@ -11,13 +25,15 @@
|
||||
"is_entity_subscribed_by_me": null,
|
||||
"author_place_in_rating": null
|
||||
},
|
||||
"video_url": "",
|
||||
"video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"video_url_thumb": "media/1/thumb.jpg",
|
||||
"created_at": "2024-01-15T12:00:00Z",
|
||||
"updated_at": "2024-01-15T12:00:00Z",
|
||||
"title": "Заголовок",
|
||||
"total_likes": 10,
|
||||
"total_views": 100,
|
||||
"total_comments": 5,
|
||||
"was_liked_by_me": false
|
||||
"was_liked_by_me": false,
|
||||
"is_favorite": false
|
||||
}
|
||||
|
||||
|
||||
26
test/fixtures/feed/feed_blog_post_video_only_response.json
vendored
Normal file
26
test/fixtures/feed/feed_blog_post_video_only_response.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": 3,
|
||||
"state": "active",
|
||||
"image": "images/element-image-placeholder.svg",
|
||||
"images": [],
|
||||
"description": "Пост только с видео",
|
||||
"author": {
|
||||
"author_slug": "author-slug",
|
||||
"author_name": "Автор",
|
||||
"author_profile_icon": "https://example.com/photo.jpg",
|
||||
"author_is_me": false,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"author_place_in_rating": null
|
||||
},
|
||||
"video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"video_url_embed": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"video_url_thumb": "media/3/thumb.jpg",
|
||||
"created_at": "2024-01-17T12:00:00Z",
|
||||
"updated_at": "2024-01-17T12:00:00Z",
|
||||
"title": "Видео",
|
||||
"total_likes": 8,
|
||||
"total_views": 90,
|
||||
"total_comments": 2,
|
||||
"was_liked_by_me": true,
|
||||
"is_favorite": false
|
||||
}
|
||||
15
test/fixtures/feed/post_feed_empty_response.json
vendored
Normal file
15
test/fixtures/feed/post_feed_empty_response.json
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"data": [],
|
||||
"links": {
|
||||
"first": null,
|
||||
"last": null,
|
||||
"prev": null,
|
||||
"next": null
|
||||
},
|
||||
"meta": {
|
||||
"path": "/api/feed",
|
||||
"per_page": 20,
|
||||
"next_cursor": null,
|
||||
"prev_cursor": null
|
||||
}
|
||||
}
|
||||
45
test/fixtures/feed/post_feed_next_page_response.json
vendored
Normal file
45
test/fixtures/feed/post_feed_next_page_response.json
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 104,
|
||||
"post_id": 4,
|
||||
"post_author_id": 13,
|
||||
"post_author_name": "Олег Кузнецов",
|
||||
"post_author_image_url": "/storage/avatars/oleg.png",
|
||||
"post_author_profile_url": "/ru/profiles/oleg-kuznetsov",
|
||||
"post_created_at": "2024-06-04T11:00:00.000000Z",
|
||||
"post_title": "Ужин за 20 минут",
|
||||
"post_short_description": "Быстрый ужин после работы",
|
||||
"post_text_md": "Курица с овощами на сковороде.",
|
||||
"post_image_url": "/storage/posts/dinner.jpg",
|
||||
"images": [],
|
||||
"video_embed_url": null,
|
||||
"post_views_count": 88,
|
||||
"post_likes_count": 7,
|
||||
"post_comments_count": 2,
|
||||
"post_is_liked_by_user": false,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"post_url": "/ru/blog_post/4",
|
||||
"video_url_thumb": null,
|
||||
"post_state": "active",
|
||||
"post_author_detailz": {
|
||||
"slug": "oleg-kuznetsov",
|
||||
"is_consultant": false
|
||||
},
|
||||
"created_at": "2024-06-04T11:05:00.000000Z",
|
||||
"updated_at": "2024-06-04T11:05:00.000000Z"
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"first": null,
|
||||
"last": null,
|
||||
"prev": "https://example.com/api/feed?cursor=eyJpZCI6MTAzfQ",
|
||||
"next": null
|
||||
},
|
||||
"meta": {
|
||||
"path": "/api/feed",
|
||||
"per_page": 20,
|
||||
"next_cursor": null,
|
||||
"prev_cursor": "eyJpZCI6MTAzfQ"
|
||||
}
|
||||
}
|
||||
115
test/fixtures/feed/post_feed_response.json
vendored
Normal file
115
test/fixtures/feed/post_feed_response.json
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 101,
|
||||
"post_id": 1,
|
||||
"post_author_id": 10,
|
||||
"post_author_name": "Анна Иванова",
|
||||
"post_author_image_url": "/storage/avatars/anna.png",
|
||||
"post_author_profile_url": "/ru/profiles/anna-ivanova",
|
||||
"post_created_at": "2024-06-01T10:00:00.000000Z",
|
||||
"post_title": "Завтрак с овсянкой",
|
||||
"post_short_description": "Простой и полезный завтрак",
|
||||
"post_text_md": "Сегодня приготовила **овсянку** с ягодами.",
|
||||
"post_image_url": "/storage/posts/oatmeal.jpg",
|
||||
"images": [
|
||||
{
|
||||
"url": "/storage/posts/oatmeal-1.jpg",
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"url": "/storage/posts/oatmeal-2.jpg",
|
||||
"orientation": "vertical"
|
||||
}
|
||||
],
|
||||
"video_embed_url": null,
|
||||
"post_views_count": 120,
|
||||
"post_likes_count": 15,
|
||||
"post_comments_count": 4,
|
||||
"post_is_liked_by_user": false,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"post_url": "/ru/blog_post/1",
|
||||
"post_state": "active",
|
||||
"video_url_thumb": null,
|
||||
"post_author_detailz": {
|
||||
"slug": "anna-ivanova",
|
||||
"is_consultant": true
|
||||
},
|
||||
"created_at": "2024-06-01T10:05:00.000000Z",
|
||||
"updated_at": "2024-06-01T10:05:00.000000Z"
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"post_id": 2,
|
||||
"post_author_id": 11,
|
||||
"post_author_name": "Игорь Петров",
|
||||
"post_author_image_url": "/storage/avatars/igor.png",
|
||||
"post_author_profile_url": "/ru/profiles/igor-petrov",
|
||||
"post_created_at": "2024-06-02T08:30:00.000000Z",
|
||||
"post_title": "Тренировка дома",
|
||||
"post_short_description": "Короткая тренировка без оборудования",
|
||||
"post_text_md": "15 минут кардио и растяжка.",
|
||||
"post_image_url": "/storage/posts/workout.jpg",
|
||||
"images": [],
|
||||
"video_embed_url": "https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"post_views_count": 340,
|
||||
"post_likes_count": 42,
|
||||
"post_comments_count": 9,
|
||||
"post_is_liked_by_user": true,
|
||||
"is_entity_subscribed_by_me": true,
|
||||
"post_url": "/ru/blog_post/2",
|
||||
"post_state": "active",
|
||||
"post_author_detailz": {
|
||||
"slug": "igor-petrov",
|
||||
"is_consultant": false
|
||||
},
|
||||
"created_at": "2024-06-02T08:35:00.000000Z",
|
||||
"updated_at": "2024-06-02T09:00:00.000000Z"
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"post_id": 3,
|
||||
"post_author_id": 12,
|
||||
"post_author_name": "Мария Смирнова",
|
||||
"post_author_image_url": null,
|
||||
"post_author_profile_url": "/ru/profiles/maria-smirnova",
|
||||
"post_created_at": "2024-06-03T14:15:00.000000Z",
|
||||
"post_title": "Рецепт смузи",
|
||||
"post_short_description": null,
|
||||
"post_text_md": null,
|
||||
"post_image_url": null,
|
||||
"images": [
|
||||
{
|
||||
"url": "/storage/posts/smoothie.jpg",
|
||||
"orientation": "horizontal"
|
||||
}
|
||||
],
|
||||
"video_embed_url": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"post_views_count": 50,
|
||||
"post_likes_count": 3,
|
||||
"post_comments_count": 1,
|
||||
"post_is_liked_by_user": false,
|
||||
"is_entity_subscribed_by_me": null,
|
||||
"post_url": "/ru/blog_post/3",
|
||||
"post_state": "active",
|
||||
"post_author_detailz": {
|
||||
"slug": "maria-smirnova",
|
||||
"is_consultant": false
|
||||
},
|
||||
"created_at": "2024-06-03T14:20:00.000000Z",
|
||||
"updated_at": "2024-06-03T14:20:00.000000Z"
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"first": null,
|
||||
"last": null,
|
||||
"prev": null,
|
||||
"next": "https://example.com/api/feed?cursor=eyJpZCI6MTAzfQ"
|
||||
},
|
||||
"meta": {
|
||||
"path": "/api/feed",
|
||||
"per_page": 20,
|
||||
"next_cursor": "eyJpZCI6MTAzfQ",
|
||||
"prev_cursor": null
|
||||
}
|
||||
}
|
||||
81
test/fixtures/feed_fixtures.dart
vendored
Normal file
81
test/fixtures/feed_fixtures.dart
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
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 'fixture_reader.dart';
|
||||
|
||||
class FeedFixtures {
|
||||
FeedFixtures._();
|
||||
|
||||
static Map<String, dynamic> postFeedResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/post_feed_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> postFeedNextPageResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/post_feed_next_page_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> postFeedEmptyResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/post_feed_empty_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> feedBlogPostResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/feed_blog_post_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> feedBlogPostGalleryResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/feed_blog_post_gallery_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> feedBlogPostVideoOnlyResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/feed_blog_post_video_only_response.json');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> feedBlogPostGalleryAndVideoResponseJson() {
|
||||
return FixtureReader.readAsMap('feed/feed_blog_post_gallery_and_video_response.json');
|
||||
}
|
||||
|
||||
static PostFeedPaginationModel postFeedPaginationModel() {
|
||||
return PostFeedPaginationModel.fromJson(postFeedResponseJson());
|
||||
}
|
||||
|
||||
static PostFeedPaginationModel postFeedNextPagePaginationModel() {
|
||||
return PostFeedPaginationModel.fromJson(postFeedNextPageResponseJson());
|
||||
}
|
||||
|
||||
static PostFeedPaginationModel postFeedEmptyPaginationModel() {
|
||||
return PostFeedPaginationModel.fromJson(postFeedEmptyResponseJson());
|
||||
}
|
||||
|
||||
static PostFeedItemModel postFeedItem({int index = 0}) {
|
||||
return postFeedPaginationModel().data[index];
|
||||
}
|
||||
|
||||
static List<FeedBlogPostModel> mappedFeedPosts({int? currentUserId}) {
|
||||
return postFeedPaginationModel().data
|
||||
.map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static FeedBlogPostModel feedBlogPostModel() {
|
||||
return FeedBlogPostModel.fromJson(feedBlogPostResponseJson());
|
||||
}
|
||||
|
||||
static FeedBlogPostModel feedBlogPostWithGallery() {
|
||||
return FeedBlogPostModel.fromJson(feedBlogPostGalleryResponseJson());
|
||||
}
|
||||
|
||||
static FeedBlogPostModel feedBlogPostVideoOnly() {
|
||||
return FeedBlogPostModel.fromJson(feedBlogPostVideoOnlyResponseJson());
|
||||
}
|
||||
|
||||
static FeedBlogPostModel feedBlogPostGalleryAndVideo() {
|
||||
return FeedBlogPostModel.fromJson(feedBlogPostGalleryAndVideoResponseJson());
|
||||
}
|
||||
|
||||
static List<FeedBlogPostModel> blogPostsByAuthor() {
|
||||
return FixtureReader.readAsList('profile/blog_posts_by_author_response.json')
|
||||
.map((item) => FeedBlogPostModel.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
80
test/fixtures/profile/blog_posts_by_author_response.json
vendored
Normal file
80
test/fixtures/profile/blog_posts_by_author_response.json
vendored
Normal file
@@ -0,0 +1,80 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"state": "active",
|
||||
"image": "media/1/cover.jpg",
|
||||
"images": [
|
||||
{
|
||||
"id": 10,
|
||||
"url": "media/1/a.jpg",
|
||||
"position": 1,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"url": "media/1/b.jpg",
|
||||
"position": 2,
|
||||
"orientation": "vertical"
|
||||
}
|
||||
],
|
||||
"description": "Описание поста профиля",
|
||||
"author": {
|
||||
"author_slug": "test-user",
|
||||
"author_name": "Test User",
|
||||
"author_profile_icon": "https://example.com/photo.jpg",
|
||||
"author_is_me": true,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"author_place_in_rating": null
|
||||
},
|
||||
"video_url": "https://rutube.ru/video/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"video_url_embed": "https://rutube.ru/play/embed/6a62f217aa99daf00af8fa6cebebf218/",
|
||||
"video_url_thumb": "media/1/thumb.jpg",
|
||||
"created_at": "2024-01-15T12:00:00Z",
|
||||
"updated_at": "2024-01-15T12:00:00Z",
|
||||
"title": "Пост профиля",
|
||||
"total_likes": 10,
|
||||
"total_views": 100,
|
||||
"total_comments": 5,
|
||||
"was_liked_by_me": false,
|
||||
"is_favorite": false
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"state": "active",
|
||||
"image": "media/2/a.jpg",
|
||||
"images": [
|
||||
{
|
||||
"id": 20,
|
||||
"url": "media/2/a.jpg",
|
||||
"position": 1,
|
||||
"orientation": "horizontal"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"url": "media/2/b.jpg",
|
||||
"position": 2,
|
||||
"orientation": "horizontal"
|
||||
}
|
||||
],
|
||||
"description": "Галерея без видео",
|
||||
"author": {
|
||||
"author_slug": "test-user",
|
||||
"author_name": "Test User",
|
||||
"author_profile_icon": "https://example.com/photo.jpg",
|
||||
"author_is_me": true,
|
||||
"is_entity_subscribed_by_me": false,
|
||||
"author_place_in_rating": null
|
||||
},
|
||||
"video_url": "",
|
||||
"video_url_embed": null,
|
||||
"video_url_thumb": null,
|
||||
"created_at": "2024-01-16T12:00:00Z",
|
||||
"updated_at": "2024-01-16T12:00:00Z",
|
||||
"title": "Галерея",
|
||||
"total_likes": 3,
|
||||
"total_views": 40,
|
||||
"total_comments": 1,
|
||||
"was_liked_by_me": false,
|
||||
"is_favorite": false
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user