forked from eatvio/eatvio_app
feat(post_creation): added multiple image posts creation and validation
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -101,6 +101,7 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
id: post.id,
|
||||
feedBlogPostModel: post,
|
||||
userSlug: post.author.authorSlug,
|
||||
onPostsChanged: widget.profileCubit.reloadBlogPosts,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user