feat(md): Issue-746

This commit is contained in:
mso7nueh
2025-10-31 00:20:21 +03:00
parent f3af743487
commit dbd92a65ea
5 changed files with 566 additions and 33 deletions

View File

@@ -0,0 +1,502 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
class MarkdownEditor extends StatefulWidget {
const MarkdownEditor({
super.key,
this.initialText,
this.hintText,
this.hasError = false,
this.minLines = 4,
this.maxLines = 6,
this.heightPreview = 120,
this.onChanged,
this.onToolbarVisibilityChanged,
this.showInlineToolbar = true,
});
final String? initialText;
final String? hintText;
final bool hasError;
final int minLines;
final int maxLines;
final double heightPreview;
final void Function(String value)? onChanged;
final void Function(bool visible)? onToolbarVisibilityChanged;
final bool showInlineToolbar;
@override
State<MarkdownEditor> createState() => MarkdownEditorState();
}
class MarkdownEditorState extends State<MarkdownEditor> {
late final TextEditingController _controller;
final FocusNode _focusNode = FocusNode();
bool _isPreview = false;
bool _enablePanel = false;
late final KeyboardVisibilityController _keyboardVisibilityController;
late final StreamSubscription<bool> _keyboardSubscription;
bool get isToolbarVisible => _enablePanel || _isPreview;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialText ?? '');
_keyboardVisibilityController = KeyboardVisibilityController();
_keyboardSubscription = _keyboardVisibilityController.onChange.listen((visible) {
setState(() {
_enablePanel = visible;
});
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
});
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
});
}
@override
void dispose() {
_keyboardSubscription.cancel();
_focusNode.dispose();
_controller.dispose();
super.dispose();
}
bool _hasFormatting(String prefix, String suffix) {
final text = _controller.text;
final selection = _controller.selection;
if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end);
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);
return beforeCursor == prefix && afterCursor == suffix;
}
}
return false;
}
void _removeFormatting(String prefix, String suffix) {
final text = _controller.text;
final selection = _controller.selection;
if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end);
final unformattedText = selectedText.substring(prefix.length, selectedText.length - suffix.length);
final newText = text.replaceRange(selection.start, selection.end, unformattedText);
_controller.text = newText;
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + unformattedText.length);
} else {
final cursorPosition = selection.baseOffset;
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;
_controller.text = newText;
_controller.selection = TextSelection.collapsed(offset: cursorPosition - prefix.length);
}
}
widget.onChanged?.call(_controller.text);
}
void _applyMarkdownFormat(String prefix, String suffix) {
final text = _controller.text;
final selection = _controller.selection;
if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end);
final formattedText = '$prefix$selectedText$suffix';
final newText = text.replaceRange(selection.start, selection.end, formattedText);
_controller.text = newText;
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + formattedText.length);
} else {
final cursorPosition = selection.baseOffset;
final newText = '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}';
_controller.text = newText;
_controller.selection = TextSelection.collapsed(offset: cursorPosition + prefix.length);
}
widget.onChanged?.call(_controller.text);
}
void _applyHeading() {
if (_hasFormatting('# ', '')) {
_removeFormatting('# ', '');
} else {
_applyMarkdownFormat('# ', '');
}
}
void _applyBold() {
if (_hasFormatting('**', '**')) {
_removeFormatting('**', '**');
} else {
_applyMarkdownFormat('**', '**');
}
}
void _applyItalic() {
if (_hasFormatting('*', '*')) {
_removeFormatting('*', '*');
} else {
_applyMarkdownFormat('*', '*');
}
}
void _applyNumberedList() {
if (_hasFormatting('1. ', '')) {
_removeFormatting('1. ', '');
} else {
_applyMarkdownFormat('1. ', '');
}
}
void _applyBulletedList() {
if (_hasFormatting('- ', '')) {
_removeFormatting('- ', '');
} else {
_applyMarkdownFormat('- ', '');
}
}
void _applyLink() {
final text = _controller.text;
final selection = _controller.selection;
if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end);
final linkRegex = RegExp(r'^\[(.*?)\]\((.*?)\)$');
if (linkRegex.hasMatch(selectedText)) {
final match = linkRegex.firstMatch(selectedText);
final linkText = match?.group(1) ?? selectedText;
final newText = text.replaceRange(selection.start, selection.end, linkText);
_controller.text = newText;
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + linkText.length);
} else {
final formattedText = '[$selectedText](url)';
final newText = text.replaceRange(selection.start, selection.end, formattedText);
_controller.text = newText;
_controller.selection = TextSelection(baseOffset: selection.start + selectedText.length + 2, extentOffset: selection.start + selectedText.length + 5);
}
} else {
_applyMarkdownFormat('[', '](url)');
}
widget.onChanged?.call(_controller.text);
}
void _applyHorizontalRule() {
final cursorPosition = _controller.selection.baseOffset;
final text = _controller.text;
final newText = '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}';
_controller.text = newText;
_controller.selection = TextSelection.collapsed(offset: cursorPosition + 5);
widget.onChanged?.call(_controller.text);
}
void _togglePreview() {
setState(() {
_isPreview = !_isPreview;
});
if (!_isPreview) {
Future.delayed(const Duration(milliseconds: 100), () {
_focusNode.requestFocus();
});
} else {
_focusNode.unfocus();
}
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
}
Widget _mdButton(IconData icon, VoidCallback onTap) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
height: 36,
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
child: Icon(icon),
),
),
);
}
Widget _mdTextButton(String text, VoidCallback onTap) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
height: 36,
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),
),
),
),
),
);
}
Widget _buildMarkdownPreview(BuildContext context) {
return Container(
height: widget.heightPreview,
padding: const EdgeInsets.all(16),
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(
color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE0E0E0)),
),
child: SingleChildScrollView(child: _parseMarkdown(_controller.text)),
);
}
Widget buildToolbar(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: MediaQuery.of(context).size.width - 76,
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
),
child: Row(
children: [
const SizedBox(width: 8),
_mdTextButton('H', _applyHeading),
const SizedBox(width: 8),
_mdButton(Icons.format_bold, _applyBold),
const SizedBox(width: 8),
_mdButton(Icons.format_italic, _applyItalic),
const SizedBox(width: 8),
_mdButton(Icons.format_list_numbered, _applyNumberedList),
const SizedBox(width: 8),
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
const SizedBox(width: 8),
_mdButton(Icons.link, _applyLink),
const SizedBox(width: 8),
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
const SizedBox(width: 8),
],
),
),
const SizedBox(width: 8),
Container(
width: 52,
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
),
child: Row(children: [_mdButton(_isPreview ? Icons.edit : Icons.visibility, _togglePreview)]),
),
],
);
}
Widget _parseMarkdown(String text) {
if (text.isEmpty) {
return Text(widget.hintText ?? '');
}
final lines = text.split('\n');
final widgets = <Widget>[];
for (int i = 0; i < lines.length; i++) {
final line = lines[i];
if (line.trim().isEmpty) {
widgets.add(const SizedBox(height: 8));
continue;
}
if (line.startsWith('# ')) {
widgets.add(
Text(
line.substring(2),
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),
),
);
} else if (line.startsWith('### ')) {
widgets.add(
Text(
line.substring(4),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black),
),
);
} else if (line.startsWith('- ') || line.startsWith('* ')) {
widgets.add(
Padding(
padding: const EdgeInsets.only(left: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('• ', style: TextStyle(fontSize: 16)),
Expanded(child: _parseInlineMarkdown(line.substring(2))),
],
),
),
);
} else if (RegExp(r'^\d+\. ').hasMatch(line)) {
final match = RegExp(r'^(\d+)\. ').firstMatch(line);
final number = match?.group(1) ?? '1';
widgets.add(
Padding(
padding: const EdgeInsets.only(left: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$number. ', style: const TextStyle(fontSize: 16)),
Expanded(child: _parseInlineMarkdown(line.substring(number.length + 2))),
],
),
),
);
} else if (line.trim() == '---') {
widgets.add(const Divider(color: Colors.grey, thickness: 1));
} else {
widgets.add(_parseInlineMarkdown(line));
}
if (i < lines.length - 1) {
widgets.add(const SizedBox(height: 4));
}
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets);
}
Widget _parseInlineMarkdown(String text) {
final spans = <TextSpan>[];
final regex = RegExp(r'(\*\*.*?\*\*|\*.*?\*|\[.*?\]\(.*?\))');
int lastEnd = 0;
for (final match in regex.allMatches(text)) {
if (match.start > lastEnd) {
spans.add(
TextSpan(
text: text.substring(lastEnd, match.start),
style: const TextStyle(fontSize: 16, color: Colors.black),
),
);
}
final matchedText = match.group(0)!;
if (matchedText.startsWith('**') && matchedText.endsWith('**')) {
spans.add(
TextSpan(
text: matchedText.substring(2, matchedText.length - 2),
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),
),
);
} else if (matchedText.startsWith('[') && matchedText.contains('](')) {
final linkMatch = RegExp(r'\[(.*?)\]\((.*?)\)').firstMatch(matchedText);
if (linkMatch != null) {
final linkText = linkMatch.group(1)!;
spans.add(
TextSpan(
text: linkText,
style: const TextStyle(fontSize: 16, color: Colors.blue, decoration: TextDecoration.underline),
),
);
}
}
lastEnd = match.end;
}
if (lastEnd < text.length) {
spans.add(
TextSpan(
text: text.substring(lastEnd),
style: const TextStyle(fontSize: 16, color: Colors.black),
),
);
}
return RichText(text: TextSpan(children: spans));
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_isPreview)
_buildMarkdownPreview(context)
else
CustomTextFormField(
controller: _controller,
focusNode: _focusNode,
maxLines: widget.maxLines,
minLines: widget.minLines,
hintText: widget.hintText,
hasError: widget.hasError,
onChanged: (value) => widget.onChanged?.call(value),
),
const SizedBox(height: 8),
if (widget.showInlineToolbar && (isToolbarVisible || !_isPreview))
Row(
children: [
Expanded(
child: Container(
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
],
),
child: Row(
children: [
const SizedBox(width: 8),
_mdTextButton('H', _applyHeading),
const SizedBox(width: 8),
_mdButton(Icons.format_bold, _applyBold),
const SizedBox(width: 8),
_mdButton(Icons.format_italic, _applyItalic),
const SizedBox(width: 8),
_mdButton(Icons.format_list_numbered, _applyNumberedList),
const SizedBox(width: 8),
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
const SizedBox(width: 8),
_mdButton(Icons.link, _applyLink),
const SizedBox(width: 8),
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
const SizedBox(width: 8),
],
),
),
),
const SizedBox(width: 8),
Container(
width: 52,
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
],
),
child: Row(children: [_mdButton(_isPreview ? Icons.edit : Icons.visibility, _togglePreview)]),
),
],
),
],
);
}
}

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -54,6 +55,8 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
final nameController = TextEditingController();
final descriptionController = TextEditingController();
final scaffoldKey = GlobalKey<ScaffoldState>();
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
bool _mdToolbarVisible = false;
@override
void initState() {
@@ -92,6 +95,8 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
return Scaffold(
backgroundColor: appTheme.gray100,
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null ? _mdKey.currentState!.buildToolbar(context) : null,
appBar: AppBar(
centerTitle: true,
title: Text(
@@ -122,20 +127,19 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
),
if (state.dietNameError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.dietNameError ?? '')],
const SizedBox(height: 12),
SizedBox(
height: 132,
child: CustomTextFormField(
hasError: state.dietDescriptionError != null,
controller: descriptionController,
textAlignVertical: TextAlignVertical.top,
maxLines: null,
minLines: null,
expands: true,
hintText: s.enter_diet_description,
onChanged: (value) {
context.read<CreateDietCubit>().setDetails(value);
},
),
MarkdownEditor(
key: _mdKey,
initialText: descriptionController.text,
hintText: s.enter_diet_description,
hasError: state.dietDescriptionError != null,
minLines: 6,
maxLines: 8,
showInlineToolbar: false,
onToolbarVisibilityChanged: (v) => setState(() => _mdToolbarVisible = v),
onChanged: (value) {
descriptionController.text = value;
context.read<CreateDietCubit>().setDetails(value);
},
),
if (state.dietDescriptionError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.dietDescriptionError ?? '')],
const SizedBox(height: 12),

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -32,9 +33,17 @@ class CreatePlanScreen extends StatelessWidget {
}
}
class CreatePlanScreenContent extends StatelessWidget {
class CreatePlanScreenContent extends StatefulWidget {
const CreatePlanScreenContent({super.key});
@override
State<CreatePlanScreenContent> createState() => _CreatePlanScreenContentState();
}
class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
bool _mdToolbarVisible = false;
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -54,6 +63,8 @@ class CreatePlanScreenContent extends StatelessWidget {
}
return Scaffold(
backgroundColor: appTheme.gray100,
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null ? _mdKey.currentState!.buildToolbar(context) : null,
appBar: AppBar(
centerTitle: true,
title: Text(
@@ -131,20 +142,17 @@ class CreatePlanScreenContent extends StatelessWidget {
builder: (context, state) {
return Column(
children: [
SizedBox(
height: 132,
child: CustomTextFormField(
hasError: state.validationState.descriptionHasError,
textAlignVertical: TextAlignVertical.top,
maxLength: 1000,
maxLines: null,
minLines: null,
expands: true,
hintText: s.plan_desc_example,
onChanged: (value) {
context.read<CreatePlanCubit>().setDetails(value);
},
),
MarkdownEditor(
key: _mdKey,
hintText: s.plan_desc_example,
hasError: state.validationState.descriptionHasError,
minLines: 6,
maxLines: 8,
showInlineToolbar: false,
onToolbarVisibilityChanged: (v) => setState(() => _mdToolbarVisible = v),
onChanged: (value) {
context.read<CreatePlanCubit>().setDetails(value);
},
),
if (state.validationState.descriptionHasError) ...[
const SizedBox(height: 4),

View File

@@ -8,6 +8,7 @@ import 'package:eatvio_app/core/widgets/success_published_recipe_bottom_sheet.da
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart';
import 'package:eatvio_app/features/create_recipe/presentation/widgets/info_about_recipe_widget.dart';
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
import 'package:eatvio_app/features/create_recipe/presentation/widgets/ingredients_widget.dart';
import 'package:eatvio_app/features/create_recipe/presentation/widgets/steps_widget.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
@@ -30,6 +31,8 @@ class RecipeCreateScreen extends StatefulWidget {
class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
late final CreateRecipeCubit createRecipeCubit;
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
bool _mdToolbarVisible = false;
@override
void initState() {
@@ -54,6 +57,10 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
} else {
return Scaffold(
backgroundColor: const Color(0xFFF0F2F5),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null
? _mdKey.currentState!.buildToolbar(context)
: null,
appBar: AppBar(
title: widget.recipe != null ? const Text('Редактирование рецепта') : const Text('Добавление рецепта'),
centerTitle: true,
@@ -65,7 +72,11 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
child: Column(
spacing: 16,
children: [
InfoAboutRecipeWidget(createRecipeCubit: createRecipeCubit),
InfoAboutRecipeWidget(
createRecipeCubit: createRecipeCubit,
markdownEditorKey: _mdKey,
onMarkdownToolbarVisibilityChanged: (v) => setState(() => _mdToolbarVisible = v),
),
const IngredientsWidget(),
StepsWidget(createRecipeCubit: createRecipeCubit),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/data/selectionPopupModel/selection_popup_model.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -16,8 +17,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
class InfoAboutRecipeWidget extends StatefulWidget {
final CreateRecipeCubit createRecipeCubit;
final GlobalKey<MarkdownEditorState>? markdownEditorKey;
final void Function(bool visible)? onMarkdownToolbarVisibilityChanged;
const InfoAboutRecipeWidget({super.key, required this.createRecipeCubit});
const InfoAboutRecipeWidget({super.key, required this.createRecipeCubit, this.markdownEditorKey, this.onMarkdownToolbarVisibilityChanged});
@override
State<InfoAboutRecipeWidget> createState() => _InfoAboutRecipeWidgetState();
@@ -105,19 +108,24 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
builder: (context, state) {
return Column(
children: [
CustomTextFormField(
KeyedSubtree(
key: AppKeys.recipeCreateScreenDescriptionField,
child: MarkdownEditor(
key: widget.markdownEditorKey,
hintText: 'Расскажите, почему вы выбрали именно этот рецепт? Опишите, каким будет готовое блюдо и в чём его достоинства.',
minLines: 5,
initialValue: state.entity.description,
maxLines: 5,
initialText: state.entity.description,
hasError: state.descriptionValidationError != null,
showInlineToolbar: false,
onToolbarVisibilityChanged: widget.onMarkdownToolbarVisibilityChanged,
onChanged: (value) {
widget.createRecipeCubit.replaceDescription(value);
if (state.descriptionValidationError != null) {
widget.createRecipeCubit.validateDescription();
}
},
),
),
if (state.descriptionValidationError != null) ...[
const SizedBox(height: 4),